From f7307f41999d91a7dbfe3c5b02e269fe2bea36b5 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Sun, 21 Jun 2026 19:45:08 +0300 Subject: [PATCH] feat: paginated reader sync, search crash fix, audio cache + unified auth - Sync pagination measurement with rendered layout (page geometry, spacing, images, chapter extras) - Improve split algorithm with getLineForVerticalPosition and widow/orphan handling - Fix search crash from duplicate LazyColumn keys on empty guids - Switch BookCard cover to fixed-height vertical variant - Add ExoPlayer SimpleCache + CacheDataSource for streaming audio - Unify AuthorizationInterceptor for player via PlaybackAuthProvider - Bump version to 1.9.11 (27) and update AGENTS.md --- AGENTS.md | 29 +- app/build.gradle.kts | 4 +- .../19.json | 507 ++++++++++++++++++ .../dueattendant149/bookshelf/Application.kt | 5 + .../bookshelf/data/di/AppModule.kt | 1 + .../bookshelf/data/local/dto/BookEntity.kt | 2 + .../bookshelf/data/local/room/BookDatabase.kt | 10 +- .../data/mapper/book/BookMapperImpl.kt | 2 + .../bookshelf/data/playback/di/CacheModule.kt | 37 ++ .../data/playback/di/PlaybackAuthProvider.kt | 17 + .../data/playback/di/PlaybackModule.kt | 15 +- .../data/remote/AuthorizationInterceptor.kt | 34 ++ .../bookshelfapi/BookshelfApiService.kt | 39 +- .../bookshelfapi/ServerStatusMonitor.kt | 61 +++ .../bookshelfapi/model/AudioTrackResponse.kt | 13 + .../bookshelfapi/model/UnifiedItemResponse.kt | 67 +++ .../bookshelfapi/model/UploadBookResponse.kt | 14 + .../AudiobookshelfRepositoryImpl.kt | 33 +- .../data/repository/CacheRepositoryImpl.kt | 385 ++++++++----- .../repository/RemoteLibraryRepositoryImpl.kt | 154 +++++- .../data/settings/SettingsManager.kt | 23 + .../bookshelf/domain/model/library/Book.kt | 2 + .../domain/model/reader/ReaderPage.kt | 17 + .../bookshelf/domain/model/rsvp/RsvpEngine.kt | 173 ++++++ .../bookshelf/domain/model/rsvp/RsvpToken.kt | 54 ++ .../domain/model/rsvp/RsvpTokenizer.kt | 122 +++++ .../repository/RemoteLibraryRepository.kt | 9 + .../reader/PaginateReaderTextUseCase.kt | 380 +++++++++++++ .../use_case/remote/GlobalSearchUseCase.kt | 21 + .../use_case/remote/ListDownloadsUseCase.kt | 20 + .../use_case/remote/StartDownloadUseCase.kt | 21 + .../use_case/remote/UploadBookUseCase.kt | 40 ++ .../bookshelf/domain/util/UriUtils.kt | 9 +- .../presentation/bookshelf/AudiobooksModel.kt | 41 ++ .../bookshelf/AudiobooksScreen.kt | 55 ++ .../presentation/bookshelf/BooksModel.kt | 41 ++ .../presentation/bookshelf/BooksScreen.kt | 55 ++ .../bookshelf/RemoteLibraryModel.kt | 171 +----- .../bookshelf/RemoteLibraryScreen.kt | 2 + .../bookshelf/RemoteLibraryViewModel.kt | 265 +++++++++ .../presentation/main/MainActivity.kt | 64 +-- .../presentation/navigator/Navigator.kt | 4 +- .../presentation/player/ChapterListScreen.kt | 104 ++++ .../presentation/player/PlayerModel.kt | 114 +++- .../presentation/player/PlayerScreen.kt | 12 +- .../presentation/player/PlayerState.kt | 2 + .../presentation/reader/ReaderEffect.kt | 4 + .../presentation/reader/ReaderEvent.kt | 12 + .../presentation/reader/ReaderModel.kt | 150 ++++-- .../presentation/reader/ReaderScreen.kt | 341 ++++++++---- .../presentation/reader/ReaderState.kt | 3 + .../bookshelf/presentation/rsvp/RsvpEffect.kt | 11 + .../bookshelf/presentation/rsvp/RsvpEvent.kt | 17 + .../bookshelf/presentation/rsvp/RsvpModel.kt | 159 ++++++ .../bookshelf/presentation/rsvp/RsvpScreen.kt | 41 ++ .../bookshelf/presentation/rsvp/RsvpState.kt | 18 + .../presentation/search/SearchModel.kt | 146 +++++ .../presentation/search/SearchScreen.kt | 24 + .../presentation/settings/SettingsScreen.kt | 4 + .../ui/bookshelf/RemoteLibraryContent.kt | 325 ++++------- .../ui/common/components/book/BookCard.kt | 184 +++++++ .../common/components/book/BookCardButtons.kt | 56 ++ .../bookshelf/ui/player/PlayerContent.kt | 48 +- .../bookshelf/ui/reader/ReaderBottomBar.kt | 39 +- .../bookshelf/ui/reader/ReaderContent.kt | 58 +- .../bookshelf/ui/reader/ReaderEffects.kt | 5 + .../bookshelf/ui/reader/ReaderLayout.kt | 214 +++++--- .../bookshelf/ui/reader/ReaderLayoutText.kt | 3 +- .../ui/reader/ReaderLayoutTextChapter.kt | 7 +- .../ui/reader/ReaderLayoutTextImage.kt | 7 +- .../ui/reader/ReaderLayoutTextParagraph.kt | 33 +- .../ui/reader/ReaderLayoutTextSeparator.kt | 7 +- .../ui/reader/ReaderPaginationLayout.kt | 276 ++++++++++ .../bookshelf/ui/reader/ReaderScaffold.kt | 17 +- .../bookshelf/ui/reader/ReaderTextStyle.kt | 80 +++ .../bookshelf/ui/reader/ReaderTopBar.kt | 11 + .../ui/reader/VolumeKeyWindowCallback.kt | 47 ++ .../bookshelf/ui/rsvp/RsvpContent.kt | 420 +++++++++++++++ .../bookshelf/ui/search/SearchContent.kt | 328 +++++++++++ .../bookshelf/ui/settings/SettingsContent.kt | 2 + .../bookshelf/ui/settings/SettingsScaffold.kt | 2 + .../bookshelf/ui/settings/SettingsTopBar.kt | 9 +- .../reading_mode/ReadingModeSubcategory.kt | 5 + .../components/ReaderPaginationOption.kt | 28 + .../reading_speed/ReadingSpeedSubcategory.kt | 30 ++ .../components/RsvpFontSizeOption.kt | 26 + .../components/RsvpPauseOptions.kt | 72 +++ .../reading_speed/components/RsvpWpmOption.kt | 26 + .../res/drawable/audiobook_tab_filled.xml | 16 + .../res/drawable/audiobook_tab_outlined.xml | 16 + app/src/main/res/drawable/book_tab_filled.xml | 16 + .../main/res/drawable/book_tab_outlined.xml | 16 + .../main/res/drawable/search_tab_filled.xml | 16 + .../main/res/drawable/search_tab_outlined.xml | 16 + .../main/res/drawable/settings_tab_filled.xml | 16 + .../res/drawable/settings_tab_outlined.xml | 16 + app/src/main/res/values/strings.xml | 42 +- .../bookshelfapi/BookshelfApiServiceTest.kt | 44 ++ 98 files changed, 5866 insertions(+), 893 deletions(-) create mode 100644 app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt create mode 100644 app/src/main/res/drawable/audiobook_tab_filled.xml create mode 100644 app/src/main/res/drawable/audiobook_tab_outlined.xml create mode 100644 app/src/main/res/drawable/book_tab_filled.xml create mode 100644 app/src/main/res/drawable/book_tab_outlined.xml create mode 100644 app/src/main/res/drawable/search_tab_filled.xml create mode 100644 app/src/main/res/drawable/search_tab_outlined.xml create mode 100644 app/src/main/res/drawable/settings_tab_filled.xml create mode 100644 app/src/main/res/drawable/settings_tab_outlined.xml diff --git a/AGENTS.md b/AGENTS.md index 0efedc82..37a323a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,10 +77,35 @@ - Audiobook player with ExoPlayer + MediaSession. - TTS jobs screen via bookshelf-api. - Reading and playback progress sync with periodic WorkManager worker. -- Current: `feature/ui-polish` builds and produces `app-debug.apk` (~58 MB). Debug APK uploaded as `BookStory-debug-6.apk` to ownCloud. +- Current: `feature/ui-polish` builds and produces `app-debug.apk` (~58 MB). Debug APK uploaded as `KRait-debug-15.apk` to ownCloud + caddy download site. - Polished: PlayerContent and TtsContent labels moved to `strings.xml`; TTS WorkManager observer leak fixed (`TtsModel` removes observer in `onCleared`). - Raised `minSdk` from 26 to 31. -- Next: backend endpoint parity for progress sync, test on device, push to Forgejo once repo is created. +- Phase 7 (nav redesign): `MainActivity` tabs = Книги | Аудиокниги | Поиск | Настройки. + - `BooksModel` / `AudiobooksModel` extend `RemoteLibraryViewModel` with `mediaTypeFilter`. + - Library filter uses name fallback ("Books" / "Audiobooks") because ABS returns `mediaType: "book"` for both. + - `SearchModel` + `SearchContent` for global Librarr search (`/api/v1/search`). + - `ServerStatusMonitor` (singleton, pings `/health` every 30 s) → auto offline-only when down. + - `SettingsScreen` is a root tab — back button hidden when stack size ≤ 1. +- Phase 8 (RSVP speed reading): core tokenizer + engine + UI. + - `domain/model/rsvp/` — `RsvpToken`, `OrpTable`, `RsvpTokenizer`, `RsvpEngine`. + - `presentation/rsvp/` — `RsvpScreen`, `RsvpModel`, `RsvpState/Event/Effect`. + - `ui/rsvp/RsvpContent.kt` — word stage with prefix/pivot/suffix, focus guide ▲▼, controls bar. + - `ReaderTopBar` got a ⚡ button → `RsvpScreen(bookId)`. + - Persists reading progress on close via `UpdateBookUseCase`. + - Settings: `rsvpWpm` (100–900), `rsvpFontSize` (24–96 sp), `rsvpPauseOnParagraph`, `rsvpPauseOnChapter`, `rsvpPauseOnLongWords`, `rsvpShowFocusGuide`. +- Phase 9 (reader pagination + polish): + - Kindle-style paginated reading mode with `HorizontalPager`. + - Volume keys (Up/Down) mapped to page navigation via `VolumeKeyWindowCallback`. + - Pagination measurement/render synchronization: exact page geometry, real inter-item spacing, image sizing, chapter title extras. + - Algorithm improvements: `TextLayoutResult.getLineForVerticalPosition()` split point, better widow/orphan handling. +- Phase 10 (bugfixes): + - Fixed search crash caused by duplicate LazyColumn keys when search results have empty `guid`. + - Fixed `BookCard` intrinsic-measurement crash risk by switching cover to fixed-height vertical variant. +- Phase 11 (audio player hardening): + - ExoPlayer streaming cache via `SimpleCache` + `CacheDataSource` (512 MB external cache). + - Unified `AuthorizationInterceptor` for player, backed by `PlaybackAuthProvider` refreshed from `ServerSettings`. +- Current APK: `KRait-debug-27.apk` (1.9.11) on `books.dueattendant149.org/download`. +- Next: final APK rebuild after pushing to Forgejo. ## WARNs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fc04bd39..87ea6277 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,8 +19,8 @@ android { applicationId = "org.dueattendant149.bookshelf" minSdk = 31 targetSdk = 36 - versionCode = 14 - versionName = "1.8.0" + versionCode = 27 + versionName = "1.9.11" vectorDrawables { useSupportLibrary = true diff --git a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json new file mode 100644 index 00000000..fe24b49c --- /dev/null +++ b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json @@ -0,0 +1,507 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "7a70be4a2d1b3fd8f89dda543d18a6b4", + "entities": [ + { + "tableName": "BookEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `itemType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "filePath", + "columnName": "filePath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scrollIndex", + "columnName": "scrollIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scrollOffset", + "columnName": "scrollOffset", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "libraryId", + "columnName": "libraryId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "mediaType", + "columnName": "mediaType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "itemType", + "columnName": "itemType", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "hasAudio", + "columnName": "hasAudio", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasEbook", + "columnName": "hasEbook", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "audioDuration", + "columnName": "audioDuration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "audioCurrentFile", + "columnName": "audioCurrentFile", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "audioCurrentPosition", + "columnName": "audioCurrentPosition", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "coverUrl", + "columnName": "coverUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "HistoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "ColorPresetEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundColor", + "columnName": "backgroundColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fontColor", + "columnName": "fontColor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSelected", + "columnName": "isSelected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "CategoryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'LAST_READ'" + }, + { + "fieldPath": "sortOrderDescending", + "columnName": "sortOrderDescending", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "audio_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_audio_file_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ], + "foreignKeys": [ + { + "table": "BookEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ebook_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "fileId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePath", + "columnName": "remotePath", + "affinity": "TEXT" + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ebook_file_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)" + } + ], + "foreignKeys": [ + { + "table": "BookEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "bookId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "cached_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookId", + "columnName": "bookId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteUrl", + "columnName": "remoteUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localPath", + "columnName": "localPath", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progress", + "columnName": "progress", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_cached_file_bookId", + "unique": false, + "columnNames": [ + "bookId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)" + }, + { + "name": "index_cached_file_type", + "unique": false, + "columnNames": [ + "type" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)" + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a70be4a2d1b3fd8f89dda543d18a6b4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/Application.kt b/app/src/main/java/org/dueattendant149/bookshelf/Application.kt index 36beddd6..0dc22cc0 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/Application.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/Application.kt @@ -12,6 +12,7 @@ import androidx.work.Configuration import androidx.work.WorkManager import dagger.hilt.android.HiltAndroidApp import org.dueattendant149.bookshelf.core.crash.CrashHandler +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker import javax.inject.Inject @@ -24,10 +25,14 @@ class Application : Application(), Configuration.Provider { @Inject lateinit var workManager: WorkManager + @Inject + lateinit var serverStatusMonitor: ServerStatusMonitor + override fun onCreate() { super.onCreate() Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) ProgressSyncWorker.schedule(workManager) + serverStatusMonitor.start() } override val workManagerConfiguration: Configuration diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt index 19092a5d..965771fa 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt @@ -67,6 +67,7 @@ object AppModule { DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields + DatabaseHelper.MANUAL_MIGRATION_18_19, // add itemType column ).allowMainThreadQueries().build().also { database -> // Additional Migrations DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt index 330d94b0..da80b059 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt @@ -32,6 +32,8 @@ data class BookEntity( val libraryId: String = "", @ColumnInfo(defaultValue = "") val mediaType: String = "", + @ColumnInfo(defaultValue = "") + val itemType: String = "", @ColumnInfo(defaultValue = "0") val hasAudio: Boolean = false, @ColumnInfo(defaultValue = "0") diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt index 4d89142a..1939a28b 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt @@ -34,7 +34,7 @@ import java.io.File EbookFileEntity::class, CachedFileEntity::class, ], - version = 18, + version = 19, autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), @@ -203,6 +203,14 @@ object DatabaseHelper { @DeleteTable("CategorySortEntity") class AUTO_MIGRATION_15_16 : AutoMigrationSpec + val MANUAL_MIGRATION_18_19 = object : Migration(18, 19) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + "ALTER TABLE BookEntity ADD COLUMN itemType TEXT NOT NULL DEFAULT ''" + ) + } + } + val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt index 22e5b3a0..771c4ba7 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt @@ -29,6 +29,7 @@ class BookMapperImpl @Inject constructor() : BookMapper { remoteId = book.remoteId, libraryId = book.libraryId, mediaType = book.mediaType, + itemType = book.itemType, hasAudio = book.hasAudio, hasEbook = book.hasEbook, audioDuration = book.audioDuration, @@ -58,6 +59,7 @@ class BookMapperImpl @Inject constructor() : BookMapper { remoteId = bookEntity.remoteId, libraryId = bookEntity.libraryId, mediaType = bookEntity.mediaType, + itemType = bookEntity.itemType, hasAudio = bookEntity.hasAudio, hasEbook = bookEntity.hasEbook, audioDuration = bookEntity.audioDuration, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt new file mode 100644 index 00000000..3eb01d73 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt @@ -0,0 +1,37 @@ +package org.dueattendant149.bookshelf.data.playback.di + +import android.content.Context +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import java.io.File +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CacheModule { + + private const val CACHE_DIR_NAME = "audiobook_cache" + private const val CACHE_SIZE_BYTES = 512L * 1024 * 1024 // 512 MB + + @Provides + @Singleton + fun provideSimpleCache( + @ApplicationContext context: Context, + ): SimpleCache { + val cacheDir = File(context.externalCacheDir ?: context.cacheDir, CACHE_DIR_NAME) + if (!cacheDir.exists()) { + cacheDir.mkdirs() + } + return SimpleCache( + cacheDir, + LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES), + StandaloneDatabaseProvider(context), + ) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt index 3126304a..a8c8dfa1 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt @@ -1,9 +1,26 @@ package org.dueattendant149.bookshelf.data.playback.di +import org.dueattendant149.bookshelf.data.settings.ServerSettings import javax.inject.Inject import javax.inject.Singleton +/** + * In-memory holder for the audiobookshelf bearer token used by playback + * components. [refresh] must be called after the token changes or before a + * fresh playback session starts. + */ @Singleton class PlaybackAuthProvider @Inject constructor() { + + @Volatile var token: String = "" + private set + + suspend fun refresh(serverSettings: ServerSettings) { + token = serverSettings.getAbsToken() ?: "" + } + + fun setToken(value: String) { + token = value.trim() + } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt index 28bc44d0..7e0550cf 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt @@ -13,6 +13,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient +import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor import javax.inject.Singleton @Module @@ -32,19 +33,7 @@ object PlaybackModule { okHttpClient: OkHttpClient, ): ExoPlayer { val client = okHttpClient.newBuilder() - .addInterceptor { chain -> - val request = chain.request() - val token = authProvider.token - if (token.isNotBlank()) { - chain.proceed( - request.newBuilder() - .header("Authorization", "Bearer $token") - .build() - ) - } else { - chain.proceed(request) - } - } + .addInterceptor(AuthorizationInterceptor(authProvider::token)) .build() val dataSourceFactory = OkHttpDataSource.Factory(client) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt new file mode 100644 index 00000000..f646e21f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt @@ -0,0 +1,34 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.data.remote + +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Adds an `Authorization: Bearer ` header when a non-blank token is + * available. The token is read lazily on every request so it can be updated + * without rebuilding the OkHttp client. + */ +class AuthorizationInterceptor( + private val tokenProvider: () -> String?, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val token = tokenProvider()?.trim() + return if (!token.isNullOrBlank()) { + chain.proceed( + request.newBuilder() + .header("Authorization", "Bearer $token") + .build() + ) + } else { + chain.proceed(request) + } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt index 3d94c0e2..0f0e3119 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt @@ -1,10 +1,12 @@ package org.dueattendant149.bookshelf.data.remote.bookshelfapi import okhttp3.ResponseBody +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.AudioTrackResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest @@ -17,15 +19,20 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesRe import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.GET -import retrofit2.http.POST +import retrofit2.http.Multipart +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query import retrofit2.http.Streaming +import retrofit2.http.POST /** * Retrofit description of the Bookshelf API (bookshelf-api:8073). @@ -41,6 +48,24 @@ interface BookshelfApiService { @GET("api/v1/books/libraries") suspend fun getLibraries(): Response> + @Streaming + @GET("api/v1/books/{itemId}/ebook") + suspend fun downloadEbook( + @Path("itemId") itemId: String, + ): Response + + @GET("api/v1/books/{itemId}/tracks") + suspend fun getAudioTracks( + @Path("itemId") itemId: String, + ): Response> + + @Streaming + @GET("api/v1/books/{itemId}/file/{fileId}") + suspend fun downloadAudioFile( + @Path("itemId") itemId: String, + @Path("fileId") fileId: String, + ): Response + @Streaming @GET("api/v1/books/{itemId}") suspend fun getBook( @@ -61,6 +86,11 @@ interface BookshelfApiService { @Body request: PlaybackProgressUpdateRequest, ): Response + @GET("api/v1/books/library/{libraryId}/items") + suspend fun getLibraryItems( + @Path("libraryId") libraryId: String, + ): Response + @GET("api/v1/books/library/{libraryId}/search") suspend fun searchLibrary( @Path("libraryId") libraryId: String, @@ -100,6 +130,13 @@ interface BookshelfApiService { @Path("jobId") jobId: String, ): Response + @Multipart + @POST("api/v1/upload/book") + suspend fun uploadBook( + @Part file: MultipartBody.Part, + @Part("book_type") bookType: RequestBody, + ): Response + // Downloads / sources @POST("api/v1/download") suspend fun startDownload( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt new file mode 100644 index 00000000..6d288f1d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt @@ -0,0 +1,61 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.data.remote.bookshelfapi + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.dueattendant149.bookshelf.data.settings.ServerSettings +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ServerStatusMonitor + @Inject + constructor( + private val clientFactory: BookshelfApiClientFactory, + private val serverSettings: ServerSettings, + ) { + private val _isOnline = MutableStateFlow(false) + val isOnline = _isOnline.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var pingJob: Job? = null + + fun start() { + if (pingJob != null) return + pingJob = scope.launch { + while (true) { + val online = checkHealth() + _isOnline.value = online + delay(30_000L) + } + } + } + + fun stop() { + pingJob?.cancel() + pingJob = null + } + + private suspend fun checkHealth(): Boolean { + val url = serverSettings.getBookshelfUrl() ?: return false + val client = clientFactory.provideClient(url) ?: return false + return runCatching { + val response = client.health() + response.isSuccessful + }.onFailure { + Log.d("ServerStatusMonitor", "Health check failed: ${it.message}") + }.getOrDefault(false) + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt new file mode 100644 index 00000000..24f86541 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt @@ -0,0 +1,13 @@ +package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AudioTrackResponse( + @SerialName("file_id") + val fileId: String, + val title: String = "", + val duration: Double = 0.0, + val size: Long = 0L, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt new file mode 100644 index 00000000..3772973a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt @@ -0,0 +1,67 @@ +package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LibraryItemsResponse( + val library: LibraryResponse = LibraryResponse(""), + val items: List = emptyList(), +) + +@Serializable +data class UnifiedItemResponse( + val id: String, + val title: String = "", + val authors: List = emptyList(), + val author: String = "", + val type: String = "", + @SerialName("media_type") + val mediaType: String = "", + @SerialName("library_id") + val libraryId: String = "", + @SerialName("cover_url") + val coverUrl: String = "", + val duration: Double = 0.0, + val size: Long = 0L, + @SerialName("progress") + val progress: UnifiedItemProgressResponse? = null, +) + +@Serializable +data class UnifiedItemProgressResponse( + val reading: ReadingProgressResponse? = null, + val playback: PlaybackProgressResponse? = null, +) + +@Serializable +data class ReadingProgressResponse( + @SerialName("itemId") + val itemId: String = "", + @SerialName("libraryId") + val libraryId: String = "", + @SerialName("scrollIndex") + val scrollIndex: Int = 0, + @SerialName("scrollOffset") + val scrollOffset: Int = 0, + val progress: Float = 0f, + @SerialName("last_chapter") + val lastChapter: String? = null, + @SerialName("updatedAt") + val updatedAt: Long = 0L, +) + +@Serializable +data class PlaybackProgressResponse( + @SerialName("itemId") + val itemId: String = "", + @SerialName("libraryId") + val libraryId: String = "", + @SerialName("current_file") + val currentFile: String = "", + @SerialName("current_position") + val currentPosition: Long = 0L, + val duration: Long = 0L, + @SerialName("updatedAt") + val updatedAt: Long = 0L, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt new file mode 100644 index 00000000..09b0b626 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt @@ -0,0 +1,14 @@ +package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UploadBookResponse( + val success: Boolean = false, + val filename: String = "", + val type: String = "", + val path: String = "", + @SerialName("error") + val errorMessage: String? = null, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt index 740c26b7..b7602133 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt @@ -7,7 +7,7 @@ package org.dueattendant149.bookshelf.data.repository import android.util.Log -import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.domain.model.player.AudioTrack import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository @@ -18,35 +18,30 @@ import javax.inject.Singleton class AudiobookshelfRepositoryImpl @Inject constructor( - private val clientFactory: AudiobookshelfApiClientFactory, + private val clientFactory: BookshelfApiClientFactory, private val serverSettings: ServerSettings, ) : AudiobookshelfRepository { override suspend fun getAudioTracks(itemId: String): Result> { return runCatching { - val url = serverSettings.getAbsUrl() - val token = serverSettings.getAbsToken() - if (url.isNullOrBlank() || token.isNullOrBlank()) { - return Result.failure(IllegalStateException("Audiobookshelf not configured")) + val bookshelfUrl = serverSettings.getBookshelfUrl() + if (bookshelfUrl.isNullOrBlank()) { + return Result.failure(IllegalStateException("Bookshelf API URL is not configured")) } - val client = clientFactory.provideClient(url, token) - ?: return Result.failure(IllegalStateException("Failed to create ABS client")) + val client = clientFactory.provideClient(bookshelfUrl) + ?: return Result.failure(IllegalStateException("Failed to create Bookshelf API client")) - val response = client.getItem(itemId) + val response = client.getAudioTracks(itemId) if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to fetch item: ${response.code()}")) + return Result.failure(RuntimeException("Failed to fetch tracks: ${response.code()}")) } - val item = response.body() - ?: return Result.failure(RuntimeException("Empty item response")) - - item.media?.audioFiles.orEmpty() - .mapIndexed { index, file -> + response.body().orEmpty() + .mapIndexed { index, track -> AudioTrack( - fileId = file.ino, - title = file.metadata?.filename?.takeIf { it.isNotBlank() } - ?: "Track ${index + 1}", - durationMs = (file.duration * 1000).toLong(), + fileId = track.fileId, + title = track.title.takeIf { it.isNotBlank() } ?: "Track ${index + 1}", + durationMs = (track.duration * 1000).toLong(), order = index, ) } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt index f667f68d..f2cf6528 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt @@ -7,22 +7,26 @@ package org.dueattendant149.bookshelf.data.repository import android.app.Application -import android.webkit.URLUtil +import android.util.Log import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.withContext +import okhttp3.ResponseBody +import org.dueattendant149.bookshelf.data.local.dto.AudioFileEntity import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity import org.dueattendant149.bookshelf.data.local.room.BookDatabase import org.dueattendant149.bookshelf.data.mapper.book.BookMapper -import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.domain.model.cache.CacheState import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus import org.dueattendant149.bookshelf.domain.model.cache.CachedFile import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository import org.dueattendant149.bookshelf.domain.repository.CacheRepository +import retrofit2.Response import java.io.File import javax.inject.Inject import javax.inject.Singleton @@ -34,156 +38,275 @@ class CacheRepositoryImpl private val application: Application, private val database: BookDatabase, private val bookMapper: BookMapper, - private val apiFactory: AudiobookshelfApiClientFactory, + private val apiFactory: BookshelfApiClientFactory, private val serverSettings: ServerSettings, + private val audiobookshelfRepository: AudiobookshelfRepository, ) : CacheRepository { private val cachedFileDao by lazy { database.cachedFileDao } + private val audioFileDao by lazy { database.audioFileDao } - override suspend fun cacheBook(book: Book): Result = runCatching { - withContext(Dispatchers.IO) { - require(book.hasEbook) { "Book has no ebook." } - require(book.remoteId.isNotBlank()) { "Book has no remote id." } + override suspend fun cacheBook(book: Book): Result = runCatching { + withContext(Dispatchers.IO) { + require(book.remoteId.isNotBlank()) { "Book has no remote id." } - val existing = database.bookDao.findBookByRemoteId(book.remoteId) - val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) } + val existing = database.bookDao.findBookByRemoteId(book.remoteId) + val localBookId = existing?.id ?: insertLocalBook(book) - if (existing != null && - cachedEbook?.localPath != null && - File(cachedEbook.localPath).exists() - ) { - if (existing.filePath != cachedEbook.localPath) { - database.bookDao.updateBook( - bookMapper.toBookEntity( - bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath) - ) - ) - } - return@withContext existing.id - } - - val url = serverSettings.getAbsUrl() - val token = serverSettings.getAbsToken() - if (url.isNullOrBlank() || token.isNullOrBlank()) { - throw IllegalStateException("Audiobookshelf server is not configured.") - } - - val client = apiFactory.provideClient(url, token) - ?: throw IllegalStateException("Could not create Audiobookshelf client.") - - val response = client.downloadBook(book.remoteId) - if (!response.isSuccessful) { - throw IllegalStateException("Download failed: ${response.code()}") - } - - val body = response.body() - ?: throw IllegalStateException("Download response body is empty.") - - val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() } - val fileName = guessFileName(response, book.remoteId) - val file = File(ebooksDir, fileName) - - body.byteStream().use { input -> - file.outputStream().use { output -> - input.copyTo(output) - } - } - - val localBook = if (existing != null) { - book.copy( - id = existing.id, - filePath = file.absolutePath, - scrollIndex = existing.scrollIndex, - scrollOffset = existing.scrollOffset, - progress = existing.progress, - categories = existing.categories, - ) - } else { - book.copy(filePath = file.absolutePath) - } - - database.bookDao.insertBook(bookMapper.toBookEntity(localBook)) - - val inserted = database.bookDao.findBookByRemoteId(book.remoteId) - ?: throw IllegalStateException("Could not insert or update book.") - - database.ebookFileDao.insert( - EbookFileEntity( - bookId = inserted.id, - fileId = book.remoteId, - format = file.extension, - localPath = file.absolutePath, - ) - ) - - cachedFileDao.insert( - CachedFileEntity( - bookId = inserted.id, - type = "ebook", - remoteUrl = "$url/api/items/${book.remoteId}/download", - localPath = file.absolutePath, - status = "completed", - progress = 1f, - ) - ) - - inserted.id + if (book.hasEbook) { + cacheEbook(book, localBookId) } + + if (book.hasAudio) { + cacheAudiobook(book, localBookId) + } + + localBookId + } + }.onFailure { + Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it) + } + + private suspend fun insertLocalBook(book: Book): Int { + database.bookDao.insertBook(bookMapper.toBookEntity(book)) + return database.bookDao.findBookByRemoteId(book.remoteId)?.id + ?: throw IllegalStateException("Could not insert book.") + } + + private suspend fun cacheEbook(book: Book, bookId: Int) { + val existingEbook = database.ebookFileDao.getByBookId(bookId) + if (existingEbook?.localPath != null && File(existingEbook.localPath).exists()) { + updateBookFilePath(book, existingEbook.localPath) + return } - override suspend fun deleteCache(remoteId: String): Result = runCatching { - withContext(Dispatchers.IO) { - val book = database.bookDao.findBookByRemoteId(remoteId) - ?: throw IllegalStateException("Book not found") + val bookshelfUrl = serverSettings.getBookshelfUrl() + ?: throw IllegalStateException("Bookshelf API URL is not configured.") + val client = apiFactory.provideClient(bookshelfUrl) + ?: throw IllegalStateException("Could not create Bookshelf API client.") + + val response = client.downloadEbook(book.remoteId) + if (!response.isSuccessful) { + throw IllegalStateException("Ebook download failed: ${response.code()}") + } + val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.") + + val file = saveFileToDir( + body, + dirName = "ebooks", + fileName = guessFileName(response, book.remoteId) + ) + + database.ebookFileDao.insert( + EbookFileEntity( + bookId = bookId, + fileId = book.remoteId, + format = file.extension, + localPath = file.absolutePath, + ) + ) + + cachedFileDao.insert( + CachedFileEntity( + bookId = bookId, + type = "ebook", + remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook", + localPath = file.absolutePath, + status = "completed", + progress = 1f, + ) + ) + + updateBookFilePath(book, file.absolutePath) + } + + private suspend fun cacheAudiobook(book: Book, bookId: Int) { + val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId) + val tracks = tracksResult.getOrThrow() + if (tracks.isEmpty()) { + Log.w("CacheRepository", "No audio tracks for ${book.remoteId}") + return + } + + val bookshelfUrl = serverSettings.getBookshelfUrl() + ?: throw IllegalStateException("Bookshelf API URL is not configured.") + val client = apiFactory.provideClient(bookshelfUrl) + ?: throw IllegalStateException("Could not create Bookshelf API client.") + + // Persist track metadata if missing. + val existingTracks = audioFileDao.getByBookId(bookId) + if (existingTracks.isEmpty()) { + audioFileDao.insertAll( + tracks.mapIndexed { index, track -> + AudioFileEntity( + bookId = bookId, + fileId = track.fileId, + remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}", + duration = track.durationMs / 1000.0, + order = index, + ) + } + ) + } + + tracks.forEachIndexed { index, track -> + val cached = audioFileDao.getByBookId(bookId) + .firstOrNull { it.fileId == track.fileId } + if (cached?.localPath != null && File(cached.localPath).exists()) { + updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath) + return@forEachIndexed + } + + updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading") + val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}" + val response = client.downloadAudioFile(book.remoteId, track.fileId) + if (!response.isSuccessful) { + updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed") + throw IllegalStateException("Audio track $index download failed: ${response.code()}") + } + val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty") + val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName) + + audioFileDao.update( + (cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId)) + .copy(localPath = file.absolutePath) + ) + + val overallProgress = (index + 1).toFloat() / tracks.size + updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed") + cachedFileDao.insert( + CachedFileEntity( + bookId = bookId, + type = "audio_${track.fileId}", + remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}", + localPath = file.absolutePath, + status = "completed", + progress = overallProgress, + ) + ) + } + } + + private suspend fun updateAudioTrackProgress( + bookId: Int, + fileId: String, + progress: Float, + localPath: String?, + status: String = "completed", + ) { + val type = "audio_$fileId" + val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull() + val entity = CachedFileEntity( + id = existing?.id ?: 0, + bookId = bookId, + type = type, + remoteUrl = existing?.remoteUrl ?: "", + localPath = localPath ?: existing?.localPath, + status = status, + progress = progress, + ) + if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity) + } + + private suspend fun updateBookFilePath(book: Book, path: String) { + val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return + if (existing.filePath != path) { + database.bookDao.updateBook( + bookMapper.toBookEntity( + bookMapper.toBook(existing).copy(filePath = path) + ) + ) + } + } + + private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File { + val dir = File(application.filesDir, dirName).apply { mkdirs() } + val file = File(dir, fileName) + body.byteStream().use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + return file + } + + private fun guessAudioExtension(fileNameOrTitle: String): String { + val name = fileNameOrTitle.lowercase() + return when { + name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b" + name.endsWith(".mp3") -> "mp3" + name.endsWith(".flac") -> "flac" + name.endsWith(".ogg") -> "ogg" + name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b" + else -> "mp3" + } + } + + override suspend fun deleteCache(remoteId: String): Result = runCatching { + withContext(Dispatchers.IO) { + val book = database.bookDao.findBookByRemoteId(remoteId) + if (book != null) { val ebook = database.ebookFileDao.getByBookId(book.id) val cachedFiles = cachedFileDao.getByBookId(book.id) + val audioFiles = audioFileDao.getByBookId(book.id) ebook?.localPath?.let { File(it).delete() } cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() } + audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() } database.ebookFileDao.deleteByBookId(book.id) cachedFileDao.deleteByBookId(book.id) + audioFileDao.deleteByBookId(book.id) } } + }.onFailure { + Log.e("CacheRepository", "deleteCache failed for $remoteId", it) + } - override fun observeCacheStatus(remoteId: String): Flow = flow { - val book = database.bookDao.findBookByRemoteId(remoteId) - if (book == null) { - emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList())) - return@flow - } - - cachedFileDao.observeByBookId(book.id).collect { entities -> - val files = entities.map { - CachedFile( - type = it.type, - status = it.status, - progress = it.progress, - localPath = it.localPath - ) - } - val state = when { - entities.isEmpty() -> CacheState.NONE - entities.all { it.status == "completed" } -> CacheState.COMPLETED - entities.any { it.status == "failed" } -> CacheState.FAILED - entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING - entities.any { it.status == "pending" } -> CacheState.PENDING - else -> CacheState.NONE - } - val progress = if (entities.isEmpty()) { - 0f - } else { - entities.map { it.progress }.average().toFloat() - } - emit(CacheStatus(remoteId, state, progress, files)) - } + override fun observeCacheStatus(remoteId: String): Flow = flow { + val book = database.bookDao.findBookByRemoteId(remoteId) + if (book == null) { + emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList())) + return@flow } - private fun guessFileName(response: retrofit2.Response, remoteId: String): String { - val contentDisposition = response.headers()["Content-Disposition"] - val mimeType = response.body()?.contentType()?.toString() - val url = response.raw().request.url.toString() - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) - return if (name.contains(".")) name else "$remoteId.epub" + cachedFileDao.observeByBookId(book.id).collect { entities -> + val files = entities.map { + CachedFile( + type = it.type, + status = it.status, + progress = it.progress, + localPath = it.localPath + ) + } + val state = when { + entities.isEmpty() -> CacheState.NONE + entities.all { it.status == "completed" } -> CacheState.COMPLETED + entities.any { it.status == "failed" } -> CacheState.FAILED + entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING + entities.any { it.status == "pending" } -> CacheState.PENDING + else -> CacheState.NONE + } + val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat() + emit(CacheStatus(remoteId, state, progress, files)) } } + + private fun guessFileName(response: Response, remoteId: String): String { + val contentDisposition = response.headers()["Content-Disposition"] + val mimeType = response.body()?.contentType()?.toString() + val url = response.raw().request.url.toString() + val name = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType) + return if (name.contains(".")) name else "$remoteId.epub" + } + + /** Public accessor used by the player to read already-cached tracks. */ + suspend fun getCachedTracks(bookId: Int): List = + audioFileDao.getCachedByBookId(bookId) + + /** Public accessor used by the player to resolve a cached local file for streaming. */ + suspend fun findCachedAudioFile(bookId: Int, fileId: String): File? { + return audioFileDao.getByBookId(bookId) + .firstOrNull { it.fileId == fileId } + ?.localPath + ?.let { File(it).takeIf { f -> f.exists() } } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt index 25ac31e4..62e4539b 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt @@ -7,9 +7,21 @@ package org.dueattendant149.bookshelf.data.repository import android.util.Log +import okhttp3.MultipartBody +import okhttp3.RequestBody import org.dueattendant149.bookshelf.core.ui.UIText +import org.dueattendant149.bookshelf.data.local.room.BookDatabase +import org.dueattendant149.bookshelf.data.mapper.book.BookMapper import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary @@ -23,6 +35,8 @@ class RemoteLibraryRepositoryImpl constructor( private val clientFactory: BookshelfApiClientFactory, private val serverSettings: ServerSettings, + private val database: BookDatabase, + private val bookMapper: BookMapper, ) : RemoteLibraryRepository { override suspend fun getLibraries(): Result> { return runCatching { @@ -38,7 +52,42 @@ class RemoteLibraryRepositoryImpl } override suspend fun getBooks(libraryId: String): Result> { - return searchLibrary(libraryId, "") + return runCatching { + val client = client() ?: return Result.success(emptyList()) + val response = client.getLibraryItems(libraryId) + if (!response.isSuccessful) { + return Result.failure(RuntimeException("Failed to load library items: ${response.code()}")) + } + + val body = response.body() ?: LibraryItemsResponse() + val items = body.items.map { it.toBook() } + + // Upsert each item into the local DB keyed by remoteId, + // preserving local file path/progress if the book already exists. + items.forEach { book -> + val existing = database.bookDao.findBookByRemoteId(book.remoteId) + val toStore = if (existing != null) { + val existingBook = bookMapper.toBook(existing) + book.copy( + id = existingBook.id, + filePath = existingBook.filePath, + scrollIndex = existingBook.scrollIndex, + scrollOffset = existingBook.scrollOffset, + progress = existingBook.progress, + categories = existingBook.categories, + lastOpened = existingBook.lastOpened, + ) + } else { + book + } + database.bookDao.insertBook(bookMapper.toBookEntity(toStore)) + } + + Result.success(items) + }.getOrElse { + Log.e("RemoteLibraryRepo", "getBooks failed", it) + Result.failure(it) + } } override suspend fun searchLibrary( @@ -57,6 +106,59 @@ class RemoteLibraryRepositoryImpl } } + override suspend fun search(request: SearchRequest): Result { + return runCatching { + val client = client() ?: return Result.failure(RuntimeException("Server not configured")) + val response = client.search(request) + if (!response.isSuccessful) { + return Result.failure(RuntimeException("Search failed: ${response.code()}")) + } + response.body() ?: SearchResponse() + }.onFailure { + Log.e("RemoteLibraryRepo", "search failed", it) + } + } + + override suspend fun startDownload(request: DownloadRequest): Result { + return runCatching { + val client = client() ?: return Result.failure(RuntimeException("Server not configured")) + val response = client.startDownload(request) + if (!response.isSuccessful) { + return Result.failure(RuntimeException("Download failed: ${response.code()}")) + } + response.body() ?: DownloadResponse() + }.onFailure { + Log.e("RemoteLibraryRepo", "startDownload failed", it) + } + } + + override suspend fun listDownloads(): Result { + return runCatching { + val client = client() ?: return Result.failure(RuntimeException("Server not configured")) + val response = client.listDownloads() + if (!response.isSuccessful) { + return Result.failure(RuntimeException("List downloads failed: ${response.code()}")) + } + response.body() ?: DownloadsListResponse() + }.onFailure { + Log.e("RemoteLibraryRepo", "listDownloads failed", it) + } + } + + override suspend fun uploadBook( + file: MultipartBody.Part, + bookType: RequestBody, + ): Result = runCatching { + val client = client() ?: return Result.failure(RuntimeException("Server not configured")) + val response = client.uploadBook(file, bookType) + if (!response.isSuccessful) { + return Result.failure(RuntimeException("Upload failed: ${response.code()}")) + } + response.body() ?: UploadBookResponse(success = false, errorMessage = "Empty response") + }.onFailure { + Log.e("RemoteLibraryRepo", "uploadBook failed", it) + } + private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) } private fun LibraryResponse.toDomain() = @@ -67,7 +169,54 @@ class RemoteLibraryRepositoryImpl itemCount = itemCount, ) - private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() = + private suspend fun UnifiedItemResponse.toBook(): Book { + val resolvedCoverUrl = resolveCoverUrl(coverUrl) + return Book( + id = 0, + title = title, + author = UIText.StringValue( + author.takeIf { it.isNotBlank() } + ?: authors.filter { it.isNotBlank() }.joinToString(", ") + .takeIf { it.isNotBlank() } + ?: "" + ), + description = null, + filePath = "", + coverImage = null, + scrollIndex = progress?.reading?.scrollIndex ?: 0, + scrollOffset = progress?.reading?.scrollOffset ?: 0, + progress = progress?.reading?.progress ?: 0f, + lastOpened = null, + categories = emptyList(), + remoteId = id, + libraryId = libraryId, + mediaType = mediaType, + itemType = type, + hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid", + hasEbook = type == "ebook" || type == "hybrid", + audioDuration = progress?.playback?.duration ?: duration.toLong(), + audioCurrentFile = progress?.playback?.currentFile ?: "", + audioCurrentPosition = progress?.playback?.currentPosition ?: 0L, + coverUrl = resolvedCoverUrl, + lastSyncedAt = System.currentTimeMillis(), + ) + } + + private suspend fun resolveCoverUrl(coverUrl: String): String { + if (coverUrl.isBlank()) return "" + val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl + val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/') + return when { + coverUrl.startsWith("http://", ignoreCase = true) || + coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl + + coverUrl.startsWith("/") -> "$base$coverUrl" + + else -> "$base/$coverUrl" + } + } + + private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() = Book( id = 0, title = title, @@ -83,6 +232,7 @@ class RemoteLibraryRepositoryImpl remoteId = id, libraryId = libraryId, mediaType = mediaType, + itemType = mediaType, hasAudio = mediaType == "audiobook" || mediaType == "podcast", hasEbook = mediaType == "book", audioDuration = duration.toLong(), diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt index 2a6165da..4740de2c 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt @@ -180,6 +180,26 @@ class SettingsManager @Inject constructor( val perceptionExpanderThickness = setting( key = intPreferencesKey("perception_expander_thickness"), default = 4 ) + + // RSVP (speed reading) + val rsvpWpm = setting( + key = intPreferencesKey("rsvp_wpm"), default = 350 + ) + val rsvpFontSize = setting( + key = intPreferencesKey("rsvp_font_size"), default = 56 + ) + val rsvpPauseOnParagraph = setting( + key = booleanPreferencesKey("rsvp_pause_paragraph"), default = true + ) + val rsvpPauseOnChapter = setting( + key = booleanPreferencesKey("rsvp_pause_chapter"), default = true + ) + val rsvpPauseOnLongWords = setting( + key = booleanPreferencesKey("rsvp_pause_long_words"), default = true + ) + val rsvpShowFocusGuide = setting( + key = booleanPreferencesKey("rsvp_show_focus_guide"), default = true + ) val horizontalLimiter = setting( key = booleanPreferencesKey("horizontal_limiter"), default = false ) @@ -211,6 +231,9 @@ class SettingsManager @Inject constructor( key = doublePreferencesKey("screen_brightness"), default = 0.5f, serialize = { it.toDouble() }, deserialize = { it.toFloat() } ) + val readerPagination = setting( + key = booleanPreferencesKey("reader_pagination"), default = true + ) val horizontalGesture = setting( key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF, serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt index aaa25ee4..120299a0 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt @@ -35,6 +35,7 @@ data class Book( val remoteId: String = "", val libraryId: String = "", val mediaType: String = "", + val itemType: String = "", val hasAudio: Boolean = false, val hasEbook: Boolean = false, val audioDuration: Long = 0L, @@ -59,6 +60,7 @@ data class Book( remoteId = "", libraryId = "", mediaType = "", + itemType = "", hasAudio = false, hasEbook = false, audioDuration = 0L, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt new file mode 100644 index 00000000..f1e3563e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt @@ -0,0 +1,17 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.model.reader + +import androidx.compose.runtime.Immutable + +@Immutable +data class ReaderPage( + val index: Int, + val items: List, + val startTextIndex: Int, + val endTextIndex: Int, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt new file mode 100644 index 00000000..75baba3e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt @@ -0,0 +1,173 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.model.rsvp + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Drives RSVP playback for a fixed token stream. + * + * @param scope scope used for the playback loop (typically `viewModelScope`). + */ +class RsvpEngine(private val scope: CoroutineScope) { + + data class State( + val currentIndex: Int = 0, + val isPlaying: Boolean = false, + val tokens: List = emptyList(), + val wpm: Int = 350, + val pauseOnParagraphEnd: Boolean = true, + val pauseOnChapterEnd: Boolean = true, + val pauseOnLongWords: Boolean = true, + ) { + val progress: Float + get() = if (tokens.isEmpty()) 0f + else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1) + + val currentToken: RsvpToken? + get() = tokens.getOrNull(currentIndex) + + val isAtEnd: Boolean + get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex + } + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + private var playbackJob: Job? = null + + fun setTokens(tokens: List) { + stop() + _state.value = _state.value.copy( + tokens = tokens, + currentIndex = 0, + isPlaying = false, + ) + } + + fun setWpm(wpm: Int) { + _state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500)) + } + + fun setPauseOnParagraphEnd(value: Boolean) { + _state.value = _state.value.copy(pauseOnParagraphEnd = value) + } + + fun setPauseOnChapterEnd(value: Boolean) { + _state.value = _state.value.copy(pauseOnChapterEnd = value) + } + + fun setPauseOnLongWords(value: Boolean) { + _state.value = _state.value.copy(pauseOnLongWords = value) + } + + fun seekToIndex(index: Int) { + val s = _state.value + if (s.tokens.isEmpty()) return + val clamped = index.coerceIn(0, s.tokens.lastIndex) + _state.value = s.copy(currentIndex = clamped) + } + + fun seekToProgress(progress: Float) { + val s = _state.value + if (s.tokens.isEmpty()) return + val clamped = progress.coerceIn(0f, 1f) + val idx = (clamped * s.tokens.lastIndex).toInt() + _state.value = s.copy(currentIndex = idx) + } + + fun skipForward(count: Int = 10) { + seekToIndex(_state.value.currentIndex + count) + } + + fun skipBackward(count: Int = 10) { + seekToIndex(_state.value.currentIndex - count) + } + + fun play() { + val s = _state.value + if (s.tokens.isEmpty()) return + if (s.isAtEnd) { + seekToIndex(0) + } + if (_state.value.isPlaying) return + _state.value = _state.value.copy(isPlaying = true) + startPlaybackLoop() + } + + fun pause() { + _state.value = _state.value.copy(isPlaying = false) + playbackJob?.cancel() + playbackJob = null + } + + fun toggle() { + if (_state.value.isPlaying) pause() else play() + } + + fun stop() { + pause() + } + + private fun startPlaybackLoop() { + playbackJob?.cancel() + playbackJob = scope.launch { + while (isActive && _state.value.isPlaying) { + val snapshot = _state.value + if (snapshot.isAtEnd) { + _state.value = snapshot.copy(isPlaying = false) + return@launch + } + + val token = snapshot.tokens[snapshot.currentIndex] + delay(delayMsFor(token, snapshot)) + + val after = _state.value + if (!after.isPlaying) return@launch + if (after.tokens.isEmpty()) return@launch + + _state.value = after.copy( + currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex) + ) + } + } + } + + /** + * Compute the delay (in ms) before advancing past [token]. + * + * Base = 60_000 / wpm, adjusted by: + * - per-word multiplier (long words / digits) + * - paragraph-end bonus (configurable, default +200 ms) + * - chapter-end bonus (configurable, default +400 ms) + */ + private fun delayMsFor(token: RsvpToken, snapshot: State): Long { + val wpm = snapshot.wpm.coerceAtLeast(50) + val baseMs = 60_000.0 / wpm + var ms = baseMs * token.multiplier + + if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) { + ms += 200.0 + } + if (token.isChapterEnd && snapshot.pauseOnChapterEnd) { + ms += 400.0 + } + if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) { + // multiplier already applied above; keep it + } else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) { + ms = baseMs + } + return ms.toLong().coerceAtLeast(20L) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt new file mode 100644 index 00000000..8fe70e5b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt @@ -0,0 +1,54 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.model.rsvp + +import androidx.compose.runtime.Immutable + +/** + * One word/segment in the RSVP stream. + * + * @param word the original word as it appears in the book. + * @param pivotIndex index of the optimal recognition point within [word]; + * characters before are [prefix], the pivot is [pivot] char, after are [suffix]. + * @param isParagraphEnd true if the token is the last word of a paragraph (longer pause). + * @param isChapterEnd true if the token follows a chapter heading. + * @param multiplier duration multiplier (1.0 baseline) for per-word pacing. + */ +@Immutable +data class RsvpToken( + val word: String, + val pivotIndex: Int, + val isParagraphEnd: Boolean, + val isChapterEnd: Boolean, + val multiplier: Float = 1f, +) { + val prefix: String + get() = word.substring(0, pivotIndex) + + val pivot: String + get() = word.substring(pivotIndex, pivotIndex + 1) + + val suffix: String + get() = word.substring(pivotIndex + 1) +} + +/** + * Optimal recognition point (ORP) lookup for word lengths 1..13+. + * Indices are 0-based positions of the focal character. + * Values follow the heuristic table from Spritz/speed-reading literature: + * 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3. + */ +object OrpTable { + private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3) + + fun pivotFor(word: String): Int { + val len = word.length + if (len == 0) return 0 + if (len <= TABLE.size) return TABLE[len - 1] + return (len * 0.3f).toInt().coerceIn(1, len - 1) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt new file mode 100644 index 00000000..bc9e0352 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt @@ -0,0 +1,122 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.model.rsvp + +import org.dueattendant149.bookshelf.domain.model.reader.ReaderText + +/** + * Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader) + * into a flat list of [RsvpToken]s suitable for RSVP playback. + * + * - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image]. + * - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`. + * - Marks the first word after a chapter heading as `isChapterEnd = true`. + * - Strips punctuation-only tokens and empty whitespace tokens. + */ +object RsvpTokenizer { + + /** + * Tokenize a list of [ReaderText] blocks (as produced by the reader parser). + */ + fun tokenizeReaderText(blocks: List): List { + val tokens = ArrayList(blocks.size * 8) + var lastWasParagraphEnd = false + var pendingChapterEnd = false + + for (block in blocks) { + when (block) { + is ReaderText.Chapter -> { + pendingChapterEnd = true + lastWasParagraphEnd = false + } + + is ReaderText.Text -> { + val raw = block.line.text + if (raw.isBlank()) { + lastWasParagraphEnd = true + } else { + tokenizeLine( + line = raw, + isParagraphEnd = lastWasParagraphEnd, + isChapterEnd = pendingChapterEnd, + out = tokens, + ) + lastWasParagraphEnd = false + pendingChapterEnd = false + } + } + + is ReaderText.Separator -> lastWasParagraphEnd = true + is ReaderText.Image -> Unit + } + } + + return tokens + } + + private fun tokenizeLine( + line: String, + isParagraphEnd: Boolean, + isChapterEnd: Boolean, + out: MutableList, + ) { + val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() } + if (words.isEmpty()) return + + val lastIndex = words.lastIndex + for ((idx, word) in words.withIndex()) { + val cleaned = cleanWord(word) + if (cleaned.isEmpty()) continue + val pivot = OrpTable.pivotFor(cleaned) + val paragraphEnd = isParagraphEnd && idx == lastIndex + val multiplier = computeMultiplier(cleaned) + out.add( + RsvpToken( + word = cleaned, + pivotIndex = pivot, + isParagraphEnd = paragraphEnd, + isChapterEnd = isChapterEnd && idx == 0, + multiplier = multiplier, + ) + ) + } + } + + private val WORD_SPLIT_REGEX = Regex("\\s+") + + private fun cleanWord(raw: String): String { + val sb = StringBuilder(raw.length) + var i = 0 + val len = raw.length + while (i < len) { + val c = raw[i] + if (c.isLetterOrDigit() || c == '-' || c == '\'') { + sb.append(c) + } + i++ + } + return sb.toString() + } + + /** + * Per-word pacing multiplier. + * + * - Long words (>9 chars) get +30% time for recognition. + * - Numbers/digits get +20% time. + * - Baseline 1.0 for normal words. + */ + private fun computeMultiplier(word: String): Float { + val len = word.length + val base = when { + len > 9 -> 1.3f + len > 6 -> 1.1f + else -> 1.0f + } + val hasDigit = word.any { it.isDigit() } + return if (hasDigit) base + 0.2f else base + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt index 7c9b8f7d..1dda4aa7 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt @@ -6,6 +6,11 @@ package org.dueattendant149.bookshelf.domain.repository +import okhttp3.MultipartBody +import okhttp3.RequestBody +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary @@ -13,4 +18,8 @@ interface RemoteLibraryRepository { suspend fun getLibraries(): Result> suspend fun getBooks(libraryId: String): Result> suspend fun searchLibrary(libraryId: String, query: String): Result> + suspend fun search(request: SearchRequest): Result + suspend fun startDownload(request: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest): Result + suspend fun listDownloads(): Result + suspend fun uploadBook(file: MultipartBody.Part, bookType: RequestBody): Result } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt new file mode 100644 index 00000000..fdd2f6d8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt @@ -0,0 +1,380 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.reader + +import android.util.Log +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Constraints +import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage +import org.dueattendant149.bookshelf.domain.model.reader.ReaderText +import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment +import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PaginateReaderTextUseCase @Inject constructor() { + + operator fun invoke( + text: List, + contentWidthPx: Int, + contentHeightPx: Int, + paragraphStyle: TextStyle, + chapterTitleAlignment: ReaderTextAlignment, + paragraphSpacingPx: Int, + textMeasurer: TextMeasurer, + imageMaxWidthPx: Int, + chapterKeepLines: Int = 2, + minWidowLines: Int = 2, + minOrphanLines: Int = 2, + safetyHeightPx: Int = 4, + chapterExtraHeightPx: Int = 0, + separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX, + defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX, + ): List { + if (text.isEmpty() || contentWidthPx <= 0 || contentHeightPx <= 0) return emptyList() + + val availableHeight = (contentHeightPx - safetyHeightPx).coerceAtLeast(1) + val workingBlocks = text.toMutableList() + val metrics = workingBlocks.map { block -> + measureBlock( + block = block, + contentWidthPx = contentWidthPx, + imageMaxWidthPx = imageMaxWidthPx, + paragraphStyle = paragraphStyle, + chapterTitleAlignment = chapterTitleAlignment, + textMeasurer = textMeasurer, + chapterExtraHeightPx = chapterExtraHeightPx, + separatorHeightPx = separatorHeightPx, + defaultImageHeightPx = defaultImageHeightPx, + ) + }.toMutableList() + + val pages = mutableListOf() + val pageItems = mutableListOf() + var startTextIndex = 0 + var remainingHeight = availableHeight + var currentPageIndex = 0 + var index = 0 + + /** + * Returns the height of [metric] plus the inter-item spacing if this + * block is not the first one on the current page. This matches the + * [Arrangement.spacedBy] used in the paginated reader UI. + */ + fun heightWithSpacing(metric: BlockMetrics): Int { + return if (pageItems.isEmpty()) metric.height else metric.height + paragraphSpacingPx + } + + fun emitPage(endTextIndex: Int) { + if (pageItems.isEmpty()) return + pages.add( + ReaderPage( + index = currentPageIndex, + items = pageItems.toList(), + startTextIndex = startTextIndex, + endTextIndex = endTextIndex, + ) + ) + currentPageIndex++ + pageItems.clear() + remainingHeight = availableHeight + startTextIndex = endTextIndex + } + + while (index < workingBlocks.size) { + val block = workingBlocks[index] + val metric = metrics[index] + val blockHeight = heightWithSpacing(metric) + + when (block) { + is ReaderText.Chapter -> { + val keep = followingTextKeepHeight( + blocks = workingBlocks, + metrics = metrics, + startIndex = index + 1, + keepLines = chapterKeepLines, + ) + // Heading itself + spacing after it (if anything follows) + kept text. + val requiredWithKeep = blockHeight + + (if (index < workingBlocks.lastIndex) paragraphSpacingPx else 0) + + keep + + when { + requiredWithKeep > availableHeight -> { + // Heading + kept lines don't fit on a fresh page. + // Place heading alone. + if (blockHeight > remainingHeight && pageItems.isNotEmpty()) { + emitPage(index) + } + pageItems.add(block) + remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0) + index++ + } + + requiredWithKeep > remainingHeight && pageItems.isNotEmpty() -> { + // Move heading to the next page so it stays with following text. + emitPage(index) + } + + else -> { + pageItems.add(block) + remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0) + index++ + } + } + } + + is ReaderText.Separator, + is ReaderText.Image -> { + if (blockHeight > remainingHeight && pageItems.isNotEmpty()) { + emitPage(index) + } + pageItems.add(block) + remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0) + index++ + } + + is ReaderText.Text -> { + if (blockHeight <= remainingHeight) { + pageItems.add(block) + remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0) + index++ + } else if (pageItems.isNotEmpty()) { + emitPage(index) + // Retry the same block on a fresh page. + } else { + // Fresh page and the paragraph still doesn't fit whole. + val split = splitParagraph( + block = block, + metric = metric, + availableHeight = remainingHeight, + minWidowLines = minWidowLines, + minOrphanLines = minOrphanLines, + ) + + if (split == null) { + // Whole paragraph fits on a fresh page but we shouldn't be here. + // Safety fallback: place it anyway. + pageItems.add(block) + remainingHeight = 0 + index++ + } else { + val (head, tail) = split + pageItems.add(ReaderText.Text(head)) + emitPage(index + 1) + // The remainder is still the same logical block, so the next page + // should start at this index rather than index + 1. + startTextIndex = index + + if (tail.isBlank()) { + index++ + } else { + // Replace current block with remainder and retry on fresh page. + val remainderBlock = ReaderText.Text(tail) + workingBlocks[index] = remainderBlock + metrics[index] = measureBlock( + block = remainderBlock, + contentWidthPx = contentWidthPx, + imageMaxWidthPx = imageMaxWidthPx, + paragraphStyle = paragraphStyle, + chapterTitleAlignment = chapterTitleAlignment, + textMeasurer = textMeasurer, + chapterExtraHeightPx = chapterExtraHeightPx, + separatorHeightPx = separatorHeightPx, + defaultImageHeightPx = defaultImageHeightPx, + ) + } + } + } + } + } + } + + if (pageItems.isNotEmpty()) { + emitPage(workingBlocks.size) + } + + Log.d("Pagination", "Split ${text.size} blocks into ${pages.size} pages") + return pages + } + + private fun measureBlock( + block: ReaderText, + contentWidthPx: Int, + imageMaxWidthPx: Int, + paragraphStyle: TextStyle, + chapterTitleAlignment: ReaderTextAlignment, + textMeasurer: TextMeasurer, + chapterExtraHeightPx: Int, + separatorHeightPx: Int, + defaultImageHeightPx: Int, + ): BlockMetrics { + return when (block) { + is ReaderText.Text -> { + val layout = measureText(block.line, paragraphStyle, contentWidthPx, textMeasurer) + val textHeight = layout?.size?.height ?: 0 + val lineCount = layout?.lineCount ?: 0 + val lineHeights = (0 until lineCount).map { lineIndex -> + layout!!.getLineBottom(lineIndex) + } + BlockMetrics( + height = textHeight, + lineHeights = lineHeights, + isSplittable = true, + layout = layout, + ) + } + + is ReaderText.Chapter -> { + val chapterStyle = readerChapterTextStyle( + nested = block.nested, + textAlignment = chapterTitleAlignment, + ) + val layout = measureText( + AnnotatedString(block.title), + chapterStyle, + contentWidthPx, + textMeasurer, + ) + val titleHeight = layout?.size?.height ?: 0 + BlockMetrics( + height = titleHeight + chapterExtraHeightPx, + isSplittable = false, + ) + } + + is ReaderText.Separator -> BlockMetrics( + height = separatorHeightPx, + isSplittable = false, + ) + + is ReaderText.Image -> BlockMetrics( + height = imageHeight(block, imageMaxWidthPx, defaultImageHeightPx), + isSplittable = false, + ) + } + } + + /** + * Returns the height of the first [keepLines] lines of the text block at + * [startIndex], or the full height of the block if it has fewer lines. If + * the next block is not splittable (e.g. an image), its full height is + * returned so the heading can be moved to the next page when necessary. + */ + private fun followingTextKeepHeight( + blocks: List, + metrics: List, + startIndex: Int, + keepLines: Int, + ): Int { + if (startIndex >= blocks.size) return 0 + val metric = metrics[startIndex] + val block = blocks[startIndex] + + if (metric.lineHeights.isEmpty()) return metric.height + + if (block !is ReaderText.Text) { + // Keep the whole non-text block with the heading. + return metric.height + } + + val linesToKeep = minOf(keepLines, metric.lineHeights.size) + return metric.lineHeights[linesToKeep - 1].toInt() + } + + private fun splitParagraph( + block: ReaderText.Text, + metric: BlockMetrics, + availableHeight: Int, + minWidowLines: Int, + minOrphanLines: Int, + ): Pair? { + val layout = metric.layout ?: return null + val lineCount = layout.lineCount + if (lineCount == 0) return null + + // Find the last line whose bottom fits inside the available height. + val lastFittingLine = layout.getLineForVerticalPosition(availableHeight.toFloat()) + .coerceIn(0, lineCount - 1) + + val maxLinesCurrent = lastFittingLine + 1 + val minLinesCurrent = minWidowLines.coerceAtLeast(1) + val minLinesRemainder = minOrphanLines.coerceAtLeast(1) + + // Ideal break: current page has at least minWidowLines, + // remainder has at least minOrphanLines. + val idealMax = (lineCount - minLinesRemainder).coerceAtLeast(minLinesCurrent) + val candidateLines = maxLinesCurrent.coerceIn(minLinesCurrent, idealMax) + + if (candidateLines in minLinesCurrent..idealMax) { + val breakChar = layout.getLineEnd(candidateLines - 1, visibleEnd = true) + if (breakChar in 1 until block.line.length) { + return block.line.subSequence(0, breakChar) to + block.line.subSequence(breakChar, block.line.length) + } + } + + // Paragraph is longer than a full page or constraints cannot be satisfied. + // Split at the last line that actually fits. + val forcedLines = maxLinesCurrent.coerceIn(1, lineCount - 1) + val breakChar = layout.getLineEnd(forcedLines - 1, visibleEnd = true) + if (breakChar in 1 until block.line.length) { + return block.line.subSequence(0, breakChar) to + block.line.subSequence(breakChar, block.line.length) + } + + return null + } + + private fun measureText( + text: AnnotatedString, + style: TextStyle, + maxWidthPx: Int, + textMeasurer: TextMeasurer, + ): TextLayoutResult? { + if (text.isBlank()) return null + return try { + textMeasurer.measure( + text = text, + style = style, + constraints = Constraints(maxWidth = maxWidthPx), + softWrap = true, + ) + } catch (e: Exception) { + Log.e("Pagination", "measure failed", e) + null + } + } + + private fun imageHeight( + block: ReaderText.Image, + imageMaxWidthPx: Int, + defaultImageHeightPx: Int, + ): Int { + val bitmap = block.imageBitmap + val width = bitmap.width + val height = bitmap.height + if (width <= 0 || height <= 0) return defaultImageHeightPx + return (imageMaxWidthPx * height / width).toInt() + } + + private data class BlockMetrics( + val height: Int, + val lineHeights: List = emptyList(), + val isSplittable: Boolean = false, + val layout: TextLayoutResult? = null, + ) + + companion object { + private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12 + private const val DEFAULT_IMAGE_HEIGHT_PX = 720 + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt new file mode 100644 index 00000000..371e3ddc --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt @@ -0,0 +1,21 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.remote + +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse +import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository +import javax.inject.Inject + +class GlobalSearchUseCase + @Inject + constructor( + private val repository: RemoteLibraryRepository, + ) { + suspend operator fun invoke(request: SearchRequest): Result = + repository.search(request) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt new file mode 100644 index 00000000..f28f148d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt @@ -0,0 +1,20 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.remote + +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse +import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository +import javax.inject.Inject + +class ListDownloadsUseCase + @Inject + constructor( + private val repository: RemoteLibraryRepository, + ) { + suspend operator fun invoke(): Result = + repository.listDownloads() + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt new file mode 100644 index 00000000..d521511b --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt @@ -0,0 +1,21 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.domain.use_case.remote + +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse +import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository +import javax.inject.Inject + +class StartDownloadUseCase + @Inject + constructor( + private val repository: RemoteLibraryRepository, + ) { + suspend operator fun invoke(request: DownloadRequest): Result = + repository.startDownload(request) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt new file mode 100644 index 00000000..eb639d39 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt @@ -0,0 +1,40 @@ +package org.dueattendant149.bookshelf.domain.use_case.remote + +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository +import java.io.File +import javax.inject.Inject + +class UploadBookUseCase + @Inject + constructor( + private val repository: RemoteLibraryRepository, + ) { + suspend operator fun invoke(file: File, bookType: String): Result { + val mediaType = file.name.let { name -> + when { + name.endsWith(".epub", ignoreCase = true) -> "application/epub+zip" + name.endsWith(".fb2", ignoreCase = true) -> "application/fb2+xml" + name.endsWith(".pdf", ignoreCase = true) -> "application/pdf" + name.endsWith(".mobi", ignoreCase = true) -> "application/x-mobipocket-ebook" + name.endsWith(".mp3", ignoreCase = true) -> "audio/mpeg" + name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> "audio/mp4" + name.endsWith(".zip", ignoreCase = true) -> "application/zip" + else -> "application/octet-stream" + } + } + val filePart = MultipartBody.Part.createFormData( + "file", + file.name, + file.asRequestBody(mediaType.toMediaTypeOrNull()), + ) + val typePart = bookType.toRequestBody("text/plain".toMediaTypeOrNull()) + return repository.uploadBook(filePart, typePart).map { response -> + if (response.success) "Uploaded: ${response.filename}" + else response.errorMessage ?: "Upload failed" + } + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt index a7876fc2..403a88f8 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt @@ -22,12 +22,17 @@ fun String.isValidUri(): Boolean { */ fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this) +private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$") + /** - * Adds https:// if the string has no http/https scheme. + * Adds https:// (or http:// for bare IP addresses) if the string has no scheme. */ fun String.ensureUriScheme(): String { val trimmed = trim() - return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed" + if (trimmed.hasUriScheme()) return trimmed + val hostPart = trimmed.substringBefore("/") + val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://" + return "$scheme$trimmed" } /** diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt new file mode 100644 index 00000000..66efe568 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt @@ -0,0 +1,41 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.bookshelf + +import dagger.hilt.android.lifecycle.HiltViewModel +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor +import org.dueattendant149.bookshelf.data.settings.ServerSettings +import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase +import javax.inject.Inject + +@HiltViewModel +class AudiobooksModel + @Inject + constructor( + fetchLibrariesUseCase: FetchLibrariesUseCase, + fetchBooksUseCase: FetchBooksUseCase, + cacheBookUseCase: CacheBookUseCase, + deleteCacheUseCase: DeleteCacheUseCase, + getCacheStatusUseCase: GetCacheStatusUseCase, + serverSettings: ServerSettings, + serverStatusMonitor: ServerStatusMonitor, + ) : RemoteLibraryViewModel( + fetchLibrariesUseCase, + fetchBooksUseCase, + cacheBookUseCase, + deleteCacheUseCase, + getCacheStatusUseCase, + serverSettings, + serverStatusMonitor, + ) { + override val libraryNameFilter: String? = "audiobook" + override val itemTypeFilter: String? = "audiobook" + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt new file mode 100644 index 00000000..4c864971 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt @@ -0,0 +1,55 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.bookshelf + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.parcelize.Parcelize +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.presentation.history.HistoryScreen +import org.dueattendant149.bookshelf.presentation.navigator.Screen +import org.dueattendant149.bookshelf.presentation.player.PlayerScreen +import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen +import org.dueattendant149.bookshelf.presentation.tts.TtsScreen +import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen +import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent +import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator + +@Parcelize +object AudiobooksScreen : Screen, Parcelable { + + @Composable + override fun Content() { + val model = hiltViewModel() + val navigator = LocalNavigator.current + + LaunchedEffect(model.effects, navigator) { + model.effects.collectLatest { effect -> + when (effect) { + is RemoteLibraryEffect.OnNavigateToReader -> { + HistoryScreen.insertHistoryChannel.trySend(effect.bookId) + navigator.push(ReaderScreen(effect.bookId)) + } + } + } + } + + RemoteLibraryContent( + model = model, + titleRes = R.string.audiobooks_screen, + onPlayAudio = { book -> + navigator.push(PlayerScreen(book)) + }, + navigateToTts = { bookRemoteId -> + // Not used for audiobooks; secondary button opens chapters. + } + ) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt new file mode 100644 index 00000000..9fa58b73 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt @@ -0,0 +1,41 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.bookshelf + +import dagger.hilt.android.lifecycle.HiltViewModel +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor +import org.dueattendant149.bookshelf.data.settings.ServerSettings +import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase +import javax.inject.Inject + +@HiltViewModel +class BooksModel + @Inject + constructor( + fetchLibrariesUseCase: FetchLibrariesUseCase, + fetchBooksUseCase: FetchBooksUseCase, + cacheBookUseCase: CacheBookUseCase, + deleteCacheUseCase: DeleteCacheUseCase, + getCacheStatusUseCase: GetCacheStatusUseCase, + serverSettings: ServerSettings, + serverStatusMonitor: ServerStatusMonitor, + ) : RemoteLibraryViewModel( + fetchLibrariesUseCase, + fetchBooksUseCase, + cacheBookUseCase, + deleteCacheUseCase, + getCacheStatusUseCase, + serverSettings, + serverStatusMonitor, + ) { + override val libraryNameFilter: String? = "book" + override val itemTypeFilter: String? = "book" + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt new file mode 100644 index 00000000..df11ddf8 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt @@ -0,0 +1,55 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.bookshelf + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.parcelize.Parcelize +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.presentation.history.HistoryScreen +import org.dueattendant149.bookshelf.presentation.navigator.Screen +import org.dueattendant149.bookshelf.presentation.player.PlayerScreen +import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen +import org.dueattendant149.bookshelf.presentation.tts.TtsScreen +import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen +import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent +import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator + +@Parcelize +object BooksScreen : Screen, Parcelable { + + @Composable + override fun Content() { + val model = hiltViewModel() + val navigator = LocalNavigator.current + + LaunchedEffect(model.effects, navigator) { + model.effects.collectLatest { effect -> + when (effect) { + is RemoteLibraryEffect.OnNavigateToReader -> { + HistoryScreen.insertHistoryChannel.trySend(effect.bookId) + navigator.push(ReaderScreen(effect.bookId)) + } + } + } + } + + RemoteLibraryContent( + model = model, + titleRes = R.string.books_screen, + onPlayAudio = { book -> + navigator.push(PlayerScreen(book)) + }, + navigateToTts = { bookRemoteId -> + navigator.push(TtsScreen(bookRemoteId)) + } + ) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt index e338c542..a8608d87 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt @@ -6,23 +6,9 @@ package org.dueattendant149.bookshelf.presentation.bookshelf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor import org.dueattendant149.bookshelf.data.settings.ServerSettings -import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus -import org.dueattendant149.bookshelf.domain.model.library.Book -import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase @@ -30,145 +16,26 @@ import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import javax.inject.Inject -data class RemoteLibraryState( - val libraries: List = emptyList(), - val books: List = emptyList(), - val selectedLibrary: RemoteLibrary? = null, - val isLoading: Boolean = false, - val error: String? = null, - val cacheStatuses: Map = emptyMap(), - val offlineOnly: Boolean = false, - val cachingBookId: String? = null, -) - @HiltViewModel class RemoteLibraryModel @Inject constructor( - private val fetchLibrariesUseCase: FetchLibrariesUseCase, - private val fetchBooksUseCase: FetchBooksUseCase, - private val cacheBookUseCase: CacheBookUseCase, - private val deleteCacheUseCase: DeleteCacheUseCase, - private val getCacheStatusUseCase: GetCacheStatusUseCase, - private val serverSettings: ServerSettings, - ) : ViewModel() { - private val _state = MutableStateFlow(RemoteLibraryState()) - val state = _state.asStateFlow() - private var cacheStatusJob: Job? = null - - private val _effects = MutableSharedFlow() - val effects = _effects.asSharedFlow() - - init { - loadLibraries() - } - - fun loadLibraries() { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - if (!serverSettings.hasCredentials()) { - _state.update { it.copy(isLoading = false, error = "Server not configured") } - return@launch - } - val result = fetchLibrariesUseCase() - _state.update { - it.copy( - isLoading = false, - libraries = result.getOrDefault(emptyList()), - error = result.exceptionOrNull()?.message, - ) - } - } - } - - fun selectLibrary(library: RemoteLibrary) { - _state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) } - loadBooks(library.id) - } - - fun loadBooks(libraryId: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - val result = fetchBooksUseCase(libraryId) - _state.update { - it.copy( - isLoading = false, - books = result.getOrDefault(emptyList()), - error = result.exceptionOrNull()?.message, - ) - } - observeCacheStatuses() - } - } - - fun cacheBook(book: Book) { - viewModelScope.launch { - _state.update { it.copy(cachingBookId = book.remoteId) } - val result = cacheBookUseCase(book) - _state.update { it.copy(cachingBookId = null) } - if (result.isFailure) { - _state.update { - it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed") - } - } - observeCacheStatuses() - } - } - - fun deleteCache(book: Book) { - viewModelScope.launch { - val result = deleteCacheUseCase(book.remoteId) - if (result.isFailure) { - _state.update { - it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed") - } - } - } - } - - fun toggleOfflineOnly() { - _state.update { it.copy(offlineOnly = !it.offlineOnly) } - } - - fun openBook(book: Book) { - if (!book.hasEbook || book.remoteId.isBlank()) return - - viewModelScope.launch { - _state.update { it.copy(cachingBookId = book.remoteId) } - val result = withContext(Dispatchers.IO) { - cacheBookUseCase(book) - } - _state.update { it.copy(cachingBookId = null) } - - result - .onSuccess { bookId -> - _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId)) - } - .onFailure { error -> - _state.update { it.copy(error = error.message ?: "Could not open book") } - } - } - } - - private fun observeCacheStatuses() { - cacheStatusJob?.cancel() - val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() } - if (remoteIds.isEmpty()) { - _state.update { it.copy(cacheStatuses = emptyMap()) } - return - } - - cacheStatusJob = - viewModelScope.launch { - combine( - remoteIds.map { remoteId -> - getCacheStatusUseCase(remoteId) - }, - ) { statuses -> - statuses.toList().associateBy { it.remoteId } - }.collect { statuses -> - _state.update { it.copy(cacheStatuses = statuses) } - } - } - } + fetchLibrariesUseCase: FetchLibrariesUseCase, + fetchBooksUseCase: FetchBooksUseCase, + cacheBookUseCase: CacheBookUseCase, + deleteCacheUseCase: DeleteCacheUseCase, + getCacheStatusUseCase: GetCacheStatusUseCase, + serverSettings: ServerSettings, + serverStatusMonitor: ServerStatusMonitor, + ) : RemoteLibraryViewModel( + fetchLibrariesUseCase, + fetchBooksUseCase, + cacheBookUseCase, + deleteCacheUseCase, + getCacheStatusUseCase, + serverSettings, + serverStatusMonitor, + ) { + override val libraryNameFilter: String? = null + override val itemTypeFilter: String? = null } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt index 6a185c26..c0002b32 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.parcelize.Parcelize +import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.navigator.Screen import org.dueattendant149.bookshelf.presentation.player.PlayerScreen @@ -41,6 +42,7 @@ object RemoteLibraryScreen : Screen, Parcelable { RemoteLibraryContent( model = model, + titleRes = R.string.bookshelf_screen, onPlayAudio = { book -> navigator.push(PlayerScreen(book)) }, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt new file mode 100644 index 00000000..c3d73b7a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt @@ -0,0 +1,265 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.bookshelf + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor +import org.dueattendant149.bookshelf.data.settings.ServerSettings +import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary +import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase + +/** + * Shared UI state for remote-library tabs. + */ +data class RemoteLibraryState( + val libraries: List = emptyList(), + val books: List = emptyList(), + val selectedLibrary: RemoteLibrary? = null, + val isLoading: Boolean = false, + val error: String? = null, + val cacheStatuses: Map = emptyMap(), + val offlineOnly: Boolean = false, + val cachingBookId: String? = null, + val serverOnline: Boolean = true, +) + +/** + * Base ViewModel for tabs that display remote libraries filtered by media type. + * + * @param mediaTypeFilter null means no filtering (legacy "bookshelf" view). + */ +abstract class RemoteLibraryViewModel( + private val fetchLibrariesUseCase: FetchLibrariesUseCase, + private val fetchBooksUseCase: FetchBooksUseCase, + private val cacheBookUseCase: CacheBookUseCase, + private val deleteCacheUseCase: DeleteCacheUseCase, + private val getCacheStatusUseCase: GetCacheStatusUseCase, + private val serverSettings: ServerSettings, + private val serverStatusMonitor: ServerStatusMonitor, +) : ViewModel() { + + /** + * Used to pick the default library by name when the backend has multiple libraries. + */ + protected abstract val libraryNameFilter: String? + + /** + * Used to split the unified item list into Books vs Audiobooks tabs. + */ + protected abstract val itemTypeFilter: String? + + private val _state = MutableStateFlow(RemoteLibraryState()) + val state = _state.asStateFlow() + private var cacheStatusJob: Job? = null + + private val _effects = MutableSharedFlow() + val effects = _effects.asSharedFlow() + + init { + loadLibraries() + viewModelScope.launch { + serverStatusMonitor.isOnline.collect { online -> + _state.update { + if (it.serverOnline == online) it + else it.copy(serverOnline = online, offlineOnly = !online || it.offlineOnly) + } + } + } + } + + fun loadLibraries() { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + if (!serverSettings.hasCredentials()) { + _state.update { it.copy(isLoading = false, error = "Server not configured") } + return@launch + } + val result = fetchLibrariesUseCase() + val allLibraries = result.getOrDefault(emptyList()) + val libraries = allLibraries.filter { matchesLibraryFilter(it) } + + _state.update { + it.copy( + isLoading = false, + libraries = libraries, + error = result.exceptionOrNull()?.message, + ) + } + + if (libraries.isNotEmpty() && state.value.selectedLibrary == null) { + selectLibrary(libraries.first()) + } + } + } + + /** + * Matches library to filter by name pattern (e.g. "Books" vs "Audiobooks"). + * The unified endpoint returns mixed items, so we no longer filter libraries by mediaType. + */ + private fun matchesLibraryFilter(library: RemoteLibrary): Boolean { + val filter = libraryNameFilter ?: return true + val name = library.name.lowercase() + return when (filter) { + "book" -> name.contains("book") && !name.contains("audio") + "audiobook" -> name.contains("audio") + else -> true + } + } + + fun selectLibrary(library: RemoteLibrary) { + _state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) } + loadBooks(library.id) + } + + fun loadBooks(libraryId: String) { + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null) } + val result = fetchBooksUseCase(libraryId) + val allBooks = result.getOrDefault(emptyList()) + val books = if (itemTypeFilter != null) { + allBooks.filter { matchesItemTypeFilter(it) } + } else { + allBooks + } + + _state.update { + it.copy( + isLoading = false, + books = books, + error = result.exceptionOrNull()?.message, + ) + } + observeCacheStatuses() + + if (state.value.serverOnline) { + autoCacheEbooks(books) + } + } + } + + private fun autoCacheEbooks(books: List) { + // Auto-cache on tab open is disabled by design. Caching now happens + // only when the user explicitly opens a book or presses download. + } + + private fun matchesItemTypeFilter(book: Book): Boolean { + val filter = itemTypeFilter ?: return true + return when (filter) { + "book" -> book.itemType == "ebook" || book.itemType == "hybrid" + "audiobook" -> book.itemType == "audiobook" || book.itemType == "podcast" || book.itemType == "hybrid" + else -> true + } + } + + fun cacheBook(book: Book) { + viewModelScope.launch { + _state.update { it.copy(cachingBookId = book.remoteId) } + val result = cacheBookUseCase(book) + _state.update { it.copy(cachingBookId = null) } + if (result.isFailure) { + _state.update { + it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed") + } + } + observeCacheStatuses() + } + } + + fun deleteCache(book: Book) { + viewModelScope.launch { + val result = deleteCacheUseCase(book.remoteId) + if (result.isFailure) { + _state.update { + it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed") + } + } + } + } + + fun toggleOfflineOnly() { + _state.update { it.copy(offlineOnly = !it.offlineOnly) } + } + + fun openBook(book: Book) { + if (!book.hasEbook || book.remoteId.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(cachingBookId = book.remoteId) } + val result = cacheBookUseCase(book) + _state.update { it.copy(cachingBookId = null) } + + result + .onSuccess { bookId -> + if (bookId <= 0) { + _state.update { it.copy(error = "Could not open book: invalid id") } + return@onSuccess + } + _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId)) + } + .onFailure { error -> + _state.update { it.copy(error = error.message ?: "Could not open book") } + } + } + } + + /** + * Starts caching an audiobook so it can be played offline. + */ + fun cacheAudiobook(book: Book) { + if (!book.hasAudio || book.remoteId.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(cachingBookId = book.remoteId) } + val result = cacheBookUseCase(book) + _state.update { it.copy(cachingBookId = null) } + if (result.isFailure) { + _state.update { + it.copy(error = result.exceptionOrNull()?.message ?: "Could not cache audiobook") + } + } + observeCacheStatuses() + } + } + + private fun observeCacheStatuses() { + cacheStatusJob?.cancel() + val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() } + if (remoteIds.isEmpty()) { + _state.update { it.copy(cacheStatuses = emptyMap()) } + return + } + + cacheStatusJob = + viewModelScope.launch { + combine( + remoteIds.map { remoteId -> + getCacheStatusUseCase(remoteId) + }, + ) { statuses -> + statuses.toList().associateBy { it.remoteId } + }.collect { statuses -> + _state.update { it.copy(cacheStatuses = statuses) } + } + } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt index 0ca1a721..37d74ba5 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt @@ -24,13 +24,10 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.collections.immutable.persistentListOf import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.data.settings.SettingsManager -import org.dueattendant149.bookshelf.presentation.browse.BrowseModel -import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen -import org.dueattendant149.bookshelf.presentation.history.HistoryModel -import org.dueattendant149.bookshelf.presentation.history.HistoryScreen -import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryScreen -import org.dueattendant149.bookshelf.presentation.library.LibraryModel -import org.dueattendant149.bookshelf.presentation.library.LibraryScreen +import org.dueattendant149.bookshelf.presentation.bookshelf.BooksScreen +import org.dueattendant149.bookshelf.presentation.bookshelf.AudiobooksScreen +import org.dueattendant149.bookshelf.presentation.search.SearchScreen +import org.dueattendant149.bookshelf.presentation.settings.SettingsScreen import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem import org.dueattendant149.bookshelf.presentation.navigator.StackEvent import org.dueattendant149.bookshelf.presentation.settings.SettingsModel @@ -77,11 +74,6 @@ class MainActivity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - // Initializing Screen Models - val libraryModel = hiltViewModel() - val historyModel = hiltViewModel() - val browseModel = hiltViewModel() - SettingsEffects( effects = settingsModel.effects ) @@ -89,32 +81,32 @@ class MainActivity : AppCompatActivity() { ProvideSettings(settings) { val tabs = persistentListOf( NavigatorItem( - screen = LibraryScreen, - title = R.string.library_screen, - tooltip = R.string.library_content_desc, - selectedIcon = R.drawable.library_screen_filled, - unselectedIcon = R.drawable.library_screen_outlined + screen = BooksScreen, + title = R.string.books_screen, + tooltip = R.string.books_content_desc, + selectedIcon = R.drawable.book_tab_filled, + unselectedIcon = R.drawable.book_tab_outlined ), NavigatorItem( - screen = HistoryScreen, - title = R.string.history_screen, - tooltip = R.string.history_content_desc, - selectedIcon = R.drawable.history_screen_filled, - unselectedIcon = R.drawable.history_screen_outlined + screen = AudiobooksScreen, + title = R.string.audiobooks_screen, + tooltip = R.string.audiobooks_content_desc, + selectedIcon = R.drawable.audiobook_tab_filled, + unselectedIcon = R.drawable.audiobook_tab_outlined ), NavigatorItem( - screen = BrowseScreen, - title = R.string.browse_screen, - tooltip = R.string.browse_content_desc, - selectedIcon = R.drawable.browse_screen_filled, - unselectedIcon = R.drawable.browse_screen_outlined + screen = SearchScreen, + title = R.string.search_screen, + tooltip = R.string.search_content_desc_network, + selectedIcon = R.drawable.search_tab_filled, + unselectedIcon = R.drawable.search_tab_outlined ), NavigatorItem( - screen = RemoteLibraryScreen, - title = R.string.bookshelf_screen, - tooltip = R.string.bookshelf_content_desc, - selectedIcon = R.drawable.bookshelf_screen_filled, - unselectedIcon = R.drawable.bookshelf_screen_outlined + screen = SettingsScreen, + title = R.string.settings_screen, + tooltip = R.string.settings_screen, + selectedIcon = R.drawable.settings_tab_filled, + unselectedIcon = R.drawable.settings_tab_outlined ) ) @@ -129,7 +121,7 @@ class MainActivity : AppCompatActivity() { ) { Navigator( initialScreen = if (settings.showStartScreen.value) StartScreen - else LibraryScreen, + else BooksScreen, transitionSpec = { lastEvent -> when (lastEvent) { StackEvent.DEFAULT -> { @@ -145,14 +137,14 @@ class MainActivity : AppCompatActivity() { }, contentKey = { when (it) { - LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs" + BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs" else -> it } }, backHandlerEnabled = { it != StartScreen } - ) { screen -> + ) { screen -> when (screen) { - LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> { + BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> { NavigatorTabs( currentTab = screen, transitionSpec = { diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt index 8ec90827..57f5ce70 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt @@ -52,7 +52,8 @@ class Navigator @AssistedInject constructor( popping: Boolean = false, saveInBackStack: Boolean = true ) { - if (lastItem.value::class == targetScreen::class) return + val current = lastItem.value + if (current::class == targetScreen::class && current == targetScreen) return if (!saveInBackStack) items.removeLast() changeStackEvent( @@ -60,7 +61,6 @@ class Navigator @AssistedInject constructor( else StackEvent.DEFAULT ) - if (lastItem.value::class == targetScreen::class) items.removeLast() items.add(targetScreen) } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt new file mode 100644 index 00000000..e95be71e --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt @@ -0,0 +1,104 @@ +package org.dueattendant149.bookshelf.presentation.player + +import android.os.Parcelable +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.parcelize.Parcelize +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.presentation.navigator.Screen +import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator + +@Parcelize +data class ChapterListScreen(val book: Book) : Screen, Parcelable { + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + override fun Content() { + val model = hiltViewModel() + val state = model.state.collectAsStateWithLifecycle().value + val navigator = LocalNavigator.current + + Scaffold( + topBar = { + TopAppBar( + title = { Text(book.title) }, + navigationIcon = { + IconButton(onClick = { navigator.pop() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.go_back_content_desc) + ) + } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp) + ) { + itemsIndexed(state.tracks) { index, track -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { + navigator.push(PlayerScreen(book, startTrackIndex = index)) + } + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "${index + 1}. ${track.title}", + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = formatMs(track.durationMs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } +} + +private fun formatMs(ms: Long): String { + val totalSeconds = ms / 1000 + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%02d:%02d", minutes, seconds) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt index 990a29fa..6dbc3f18 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt @@ -6,7 +6,6 @@ package org.dueattendant149.bookshelf.presentation.player -import android.content.ComponentName import android.content.Context import android.content.Intent import androidx.lifecycle.ViewModel @@ -15,40 +14,88 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.LoadControl +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.trackselection.DefaultTrackSelector +import androidx.media3.exoplayer.upstream.DefaultAllocator import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.dueattendant149.bookshelf.data.local.room.BookDatabase import org.dueattendant149.bookshelf.data.mapper.book.BookMapper import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider +import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.model.player.AudioTrack import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository +import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase import org.dueattendant149.bookshelf.domain.util.fixUriScheme +import java.io.File import javax.inject.Inject @HiltViewModel class PlayerModel - @OptIn(UnstableApi::class) @Inject constructor( @ApplicationContext private val context: Context, - private val exoPlayer: ExoPlayer, private val serverSettings: ServerSettings, private val audiobookshelfRepository: AudiobookshelfRepository, - private val authProvider: PlaybackAuthProvider, private val database: BookDatabase, private val bookMapper: BookMapper, private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase, + private val cacheBookUseCase: CacheBookUseCase, + private val okHttpClient: okhttp3.OkHttpClient, + private val authProvider: PlaybackAuthProvider, + private val simpleCache: SimpleCache, ) : ViewModel() { + + @OptIn(UnstableApi::class) + val exoPlayer: ExoPlayer by lazy { + val authClient = okHttpClient.newBuilder() + .addInterceptor(AuthorizationInterceptor(authProvider::token)) + .build() + val upstreamFactory = OkHttpDataSource.Factory(authClient) + val cacheDataSourceFactory = CacheDataSource.Factory() + .setCache(simpleCache) + .setUpstreamDataSourceFactory(upstreamFactory) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + ExoPlayer.Builder(context) + .setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true)) + .setTrackSelector(DefaultTrackSelector(context)) + .setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory)) + .setLoadControl(createLoadControl()) + .build() + .also { it.addListener(listener) } + } + + private fun createLoadControl(): LoadControl { + return androidx.media3.exoplayer.DefaultLoadControl.Builder() + .setAllocator(DefaultAllocator(true, 16 * 1024)) + .setBufferDurationsMs( + /* minBufferMs = */ 30_000, + /* maxBufferMs = */ 120_000, + /* bufferForPlaybackMs = */ 2_500, + /* bufferForPlaybackAfterRebufferMs = */ 5_000, + ) + .setPrioritizeTimeOverSizeThresholds(true) + .build() + } + private val _state = MutableStateFlow(PlayerState()) val state = _state.asStateFlow() @@ -97,24 +144,22 @@ class PlayerModel } init { - exoPlayer.addListener(listener) startProgressUpdates() } - fun init(book: Book) { - if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank()) return + fun init(book: Book, startTrackIndex: Int = 0) { + if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank() && startTrackIndex == 0) return _state.update { it.copy(book = book, isLoading = true, error = null) } viewModelScope.launch { - val url = serverSettings.getAbsUrl()?.fixUriScheme() - val token = serverSettings.getAbsToken() - if (url.isNullOrBlank() || token.isNullOrBlank()) { - _state.update { it.copy(isLoading = false, error = "Audiobookshelf not configured") } + authProvider.refresh(serverSettings) + + val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme() + if (baseUrl.isNullOrBlank()) { + _state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") } return@launch } - authProvider.token = token - val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId) tracksResult.fold( onSuccess = { tracks -> @@ -123,7 +168,9 @@ class PlayerModel return@fold } _state.update { it.copy(tracks = tracks) } - loadTracks(url, tracks) + val startIndex = startTrackIndex.coerceIn(0, tracks.lastIndex) + val startPosition = if (startIndex == 0) book.audioCurrentPosition else 0L + loadTracks(baseUrl, tracks, startIndex, startPosition) startService(book) }, onFailure = { error -> @@ -133,9 +180,20 @@ class PlayerModel } } - private fun loadTracks(baseUrl: String, tracks: List) { + private suspend fun loadTracks(baseUrl: String, tracks: List, startIndex: Int = 0, startPositionMs: Long = 0L) { + val normalizedBase = baseUrl.trimEnd('/') + "/" + val bookId = database.bookDao.findBookByRemoteId(_state.value.book.remoteId)?.id + val cachedAudioFiles = bookId?.let { id -> + withContext(Dispatchers.IO) { database.audioFileDao.getByBookId(id) } + }.orEmpty() + val mediaItems = tracks.map { track -> - val uri = "${baseUrl}api/items/${_state.value.book.remoteId}/file/${track.fileId}" + val localFile = cachedAudioFiles + .firstOrNull { it.fileId == track.fileId } + ?.localPath + ?.let { path -> File(path).takeIf { it.exists() } } + val uri = localFile?.toURI()?.toString() + ?: "${normalizedBase}api/v1/books/${_state.value.book.remoteId}/file/${track.fileId}" MediaItem.Builder() .setMediaId(track.fileId) .setUri(uri) @@ -149,7 +207,7 @@ class PlayerModel .build() } - exoPlayer.setMediaItems(mediaItems) + exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs) exoPlayer.prepare() exoPlayer.play() } @@ -259,6 +317,28 @@ class PlayerModel } } + fun downloadAudiobook() { + val book = _state.value.book + if (book.remoteId.isBlank() || !book.hasAudio) return + _state.update { it.copy(isCaching = true, cacheProgress = 0f) } + + viewModelScope.launch { + val result = cacheBookUseCase(book) + result.onSuccess { + // Refresh player with local files now that caching is done. + val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme() ?: return@onSuccess + val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId) + tracksResult.onSuccess { tracks -> + _state.update { it.copy(tracks = tracks) } + loadTracks(baseUrl, tracks, exoPlayer.currentMediaItemIndex, exoPlayer.currentPosition) + } + }.onFailure { error -> + _state.update { it.copy(error = error.message, isCaching = false) } + } + _state.update { it.copy(isCaching = false, cacheProgress = 1f) } + } + } + override fun onCleared() { exoPlayer.removeListener(listener) exoPlayer.stop() diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt index ea1c6d9b..51380c92 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt @@ -17,14 +17,17 @@ import org.dueattendant149.bookshelf.presentation.navigator.Screen import org.dueattendant149.bookshelf.ui.player.PlayerContent @Parcelize -data class PlayerScreen(val book: Book) : Screen, Parcelable { +data class PlayerScreen( + val book: Book, + val startTrackIndex: Int = 0, +) : Screen, Parcelable { @Composable override fun Content() { val model = hiltViewModel() - LaunchedEffect(book) { - model.init(book) + LaunchedEffect(book, startTrackIndex) { + model.init(book, startTrackIndex) } DisposableEffect(Unit) { @@ -33,6 +36,7 @@ data class PlayerScreen(val book: Book) : Screen, Parcelable { } } + val navigator = org.dueattendant149.bookshelf.ui.navigator.LocalNavigator.current PlayerContent( state = model.state, onPlayPause = model::playPause, @@ -43,6 +47,8 @@ data class PlayerScreen(val book: Book) : Screen, Parcelable { onSkipPrevious = model::skipToPrevious, onSetSpeed = model::setSpeed, onSelectTrack = model::selectTrack, + onOpenChapters = { navigator.push(ChapterListScreen(book)) }, + onDownloadAudiobook = model::downloadAudiobook, ) } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt index 7cfc472d..c225fd50 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt @@ -9,6 +9,8 @@ data class PlayerState( val currentTrackIndex: Int = 0, val isPlaying: Boolean = false, val isLoading: Boolean = false, + val isCaching: Boolean = false, + val cacheProgress: Float = 0f, val currentPositionMs: Long = 0L, val durationMs: Long = 0L, val bufferedPositionMs: Long = 0L, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt index 229d26f9..98c36c6c 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt @@ -38,4 +38,8 @@ sealed class ReaderEffect { data class OnNavigateToBookInfo( val changePath: Boolean ) : ReaderEffect() + + data class OnNavigateToRsvp( + val bookId: Int + ) : ReaderEffect() } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt index 04e16265..6bb8b711 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt @@ -40,6 +40,14 @@ sealed class ReaderEvent { val progress: Float ) : ReaderEvent() + data class OnChangePage( + val page: Int + ) : ReaderEvent() + + data class OnPagesComputed( + val pages: List + ) : ReaderEvent() + data class OnRestoreCheckpoint( val checkpoint: Checkpoint ) : ReaderEvent() @@ -78,4 +86,8 @@ sealed class ReaderEvent { data class OnNavigateToBookInfo( val changePath: Boolean ) : ReaderEvent() + + data class OnNavigateToRsvp( + val bookId: Int + ) : ReaderEvent() } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt index a3e9f352..e6436ea5 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt @@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseC import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen @@ -53,6 +54,7 @@ class ReaderModel @Inject constructor( private val getHistoryForBookUseCase: GetHistoryForBookUseCase, private val getChapterProgressUseCase: GetChapterProgressUseCase, private val syncReadingProgressUseCase: SyncReadingProgressUseCase, + private val paginateReaderTextUseCase: PaginateReaderTextUseCase, ) : ViewModel() { private val mutex = Mutex() @@ -96,7 +98,9 @@ class ReaderModel @Inject constructor( book = it.book.copy( lastOpened = lastOpened ), - text = text + text = text, + pages = emptyList(), + currentPage = 0 ) } ensureActive() @@ -111,24 +115,34 @@ class ReaderModel @Inject constructor( } is ReaderEvent.OnRestoreScroll -> { - snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 } + val state = _state.value + if (state.pages.isNotEmpty()) { + val page = state.pages + .indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex } + .takeIf { it >= 0 } + ?: 0 + onEvent(ReaderEvent.OnChangePage(page)) + _state.update { it.copy(isLoading = false, errorMessage = null) } + } else { + snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 } - _state.value.listState.requestScrollToItem( - index = _state.value.book.scrollIndex, - scrollOffset = _state.value.book.scrollOffset - ) + state.listState.requestScrollToItem( + index = state.book.scrollIndex, + scrollOffset = state.book.scrollOffset + ) - _state.update { - val (currentChapter, currentChapterProgress) = getChapterProgressUseCase( - it.book.scrollIndex, - it.text - ) - it.copy( - currentChapter = currentChapter, - currentChapterProgress = currentChapterProgress, - isLoading = false, - errorMessage = null - ) + _state.update { + val (currentChapter, currentChapterProgress) = getChapterProgressUseCase( + it.book.scrollIndex, + it.text + ) + it.copy( + currentChapter = currentChapter, + currentChapterProgress = currentChapterProgress, + isLoading = false, + errorMessage = null + ) + } } } @@ -230,14 +244,69 @@ class ReaderModel @Inject constructor( scrollJob = viewModelScope.launch(Dispatchers.IO) { delay(300) - val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt() + val state = _state.value + if (state.pages.isNotEmpty()) { + val targetPage = (event.progress * state.pages.lastIndex).roundToInt() + onEvent(ReaderEvent.OnChangePage(targetPage)) + } else { + val scrollTo = (state.text.lastIndex * event.progress).roundToInt() + state.listState.requestScrollToItem( + index = scrollTo, + scrollOffset = 0 + ) + onEvent(ReaderEvent.OnUpdateChapter(scrollTo)) + } + } + } - _state.value.listState.requestScrollToItem( - index = scrollTo, - scrollOffset = 0 + is ReaderEvent.OnChangePage -> { + withContext(Dispatchers.Default) { + val state = _state.value + val pages = state.pages + if (pages.isEmpty()) return@withContext + val page = event.page.coerceIn(0, pages.lastIndex) + val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0 + + val (currentChapter, currentChapterProgress) = getChapterProgressUseCase( + pageStart, + state.text ) - onEvent(ReaderEvent.OnUpdateChapter(scrollTo)) + _state.update { + it.copy( + currentPage = page, + currentChapter = currentChapter, + currentChapterProgress = currentChapterProgress, + book = it.book.copy( + progress = calculateProgress(page), + scrollIndex = pageStart, + scrollOffset = 0 + ) + ) + } + + updateBookUseCase(_state.value.book) + syncReadingProgressUseCase( + _state.value.book, + _state.value.currentChapter?.title, + ) + } + } + + is ReaderEvent.OnPagesComputed -> { + withContext(Dispatchers.Default) { + val pages = event.pages + if (pages.isEmpty()) { + _state.update { it.copy(pages = emptyList(), currentPage = 0) } + return@withContext + } + // Restore the page that contains the saved scrollIndex. + val savedIndex = _state.value.book.scrollIndex + val targetPage = pages + .indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex } + .takeIf { it >= 0 } + ?: 0 + _state.update { it.copy(pages = pages, currentPage = targetPage) } } } @@ -384,6 +453,10 @@ class ReaderModel @Inject constructor( is ReaderEvent.OnNavigateToBookInfo -> { _effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath)) } + + is ReaderEvent.OnNavigateToRsvp -> { + _effects.emit(ReaderEffect.OnNavigateToRsvp(event.bookId)) + } } }.also { eventStack.add(it) } } @@ -484,22 +557,31 @@ class ReaderModel @Inject constructor( } ?: (-1 to -1) } - private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float { + private fun calculateProgress(pageOrIndex: Int? = null): Float { + val state = _state.value if ( - _state.value.isLoading || - _state.value.listState.layoutInfo.totalItemsCount == 0 || - _state.value.text.isEmpty() || - _state.value.errorMessage != null - ) return _state.value.book.progress + state.isLoading || + state.text.isEmpty() || + state.errorMessage != null + ) return state.book.progress - if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f + if (state.pages.isNotEmpty()) { + if (state.pages.size <= 1) return 0f + val page = pageOrIndex ?: state.currentPage + return page.coerceIn(0, state.pages.lastIndex) + .div(state.pages.lastIndex.toFloat()) + .coerceAndPreventNaN() + } - val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index - if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f + if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress - return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) - .div(_state.value.text.lastIndex.toFloat()) - .coerceAndPreventNaN() + val index = pageOrIndex ?: state.listState.firstVisibleItemIndex + if (index == 0) return 0f + + val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index + if (lastVisibleItemIndex >= state.text.lastIndex) return 1f + + return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN() } private suspend inline fun MutableStateFlow.update(function: (T) -> T) { diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt index 68efbb25..b142d758 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt @@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.presentation.reader import android.content.pm.ActivityInfo import android.os.Parcelable +import android.view.KeyEvent import android.view.WindowManager import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.layout.ExperimentalLayoutApi @@ -23,19 +24,29 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp @@ -55,6 +66,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness import org.dueattendant149.bookshelf.ui.reader.ReaderContent import org.dueattendant149.bookshelf.ui.reader.ReaderEffects +import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle import kotlin.math.roundToInt @Parcelize @@ -83,6 +95,11 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { ) { state.value.listState } + + val configuration = LocalConfiguration.current + val textMeasurer = rememberTextMeasurer() + + val readerPagination = settings.readerPagination.value val nestedScrollConnection = remember { derivedStateOf { object : NestedScrollConnection { @@ -155,76 +172,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { ) return@remember 0.sp (settings.paragraphIndentation.lastValue * 6).sp } - val perceptionExpanderPadding = remember( - sidePadding, - settings.perceptionExpanderPadding.value - ) { - sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp - } - val perceptionExpanderThickness = remember( - settings.perceptionExpanderThickness.value - ) { - (settings.perceptionExpanderThickness.lastValue * 0.25f).dp - } - val horizontalLimiterHeight = remember( - settings.horizontalLimiterHeight.value - ) { - (settings.horizontalLimiterHeight.lastValue * 20).dp - } - val horizontalLimiterRulerThickness = remember( - settings.horizontalLimiterRulerThickness.value - ) { - (settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp - } - val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) { - (36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp - } - val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) { - when (settings.highlightedReadingThickness.lastValue) { - 2 -> FontWeight.SemiBold - 3 -> FontWeight.Bold - else -> FontWeight.Medium - } - } - val horizontalAlignment = remember(settings.textAlignment.value) { - when (settings.textAlignment.lastValue) { - ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start - ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally - ReaderTextAlignment.END -> Alignment.End - } - } val imagesWidth = remember(settings.imagesWidth.value) { settings.imagesWidth.lastValue.coerceAtLeast(0.01f) } - val imagesCornersRoundness = remember( - settings.imagesCornersRoundness.value, - settings.imagesWidth.value - ) { - (settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp - } - val imagesColorEffects = remember( - settings.imagesColorEffects.value, - fontColor.value, - backgroundColor.value - ) { - when (settings.imagesColorEffects.lastValue) { - ReaderColorEffects.OFF -> null - - ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix( - ColorMatrix().apply { setToSaturation(0f) } - ) - - ReaderColorEffects.FONT -> ColorFilter.tint( - color = fontColor.value, - blendMode = BlendMode.Color - ) - - ReaderColorEffects.BACKGROUND -> ColorFilter.tint( - color = backgroundColor.value, - blendMode = BlendMode.Color - ) - } - } val progressBarPadding = remember(settings.progressBarPadding.value) { (settings.progressBarPadding.lastValue * 3).dp } @@ -287,6 +237,158 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { } ) } + + // Recompute pages when text or relevant settings change. + val pages by produceState( + initialValue = emptyList(), + state.value.text, + readerPagination, + settings.fontSize.value, + settings.lineHeight.value, + settings.sidePadding.value, + settings.paragraphHeight.value, + settings.textAlignment.value, + settings.fontFamily.value, + settings.fontThickness.value, + settings.italic.value, + settings.letterSpacing.value, + settings.paragraphIndentation.value, + settings.progressBarPadding.value, + settings.progressBarFontSize.value, + settings.imagesWidth.value, + settings.fullscreen.value, + settings.cutoutPadding.value, + configuration.screenWidthDp, + configuration.screenHeightDp, + ) { + value = withContext(Dispatchers.Default) { + if (!readerPagination || state.value.text.isEmpty()) { + emptyList() + } else { + val contentPaddingTopPx = with(density) { + contentPadding.calculateTopPadding().toPx() + }.toInt() + val contentPaddingBottomPx = with(density) { + contentPadding.calculateBottomPadding().toPx() + }.toInt() + val contentPaddingStartPx = with(density) { + contentPadding.calculateStartPadding(layoutDirection).toPx() + }.toInt() + val contentPaddingEndPx = with(density) { + contentPadding.calculateEndPadding(layoutDirection).toPx() + }.toInt() + + val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt() + val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt() + val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt() + + val contentWidthPx = with(density) { + configuration.screenWidthDp.dp.toPx() + }.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2 + + val progressBarHeightPx = with(density) { + (progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt() + } + + val contentHeightPx = with(density) { + configuration.screenHeightDp.dp.toPx() + }.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx + + val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt() + + val style = readerParagraphTextStyle( + fontFamily = settings.fontFamily.lastValue, + fontThickness = settings.fontThickness.lastValue, + fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal, + textAlignment = settings.textAlignment.lastValue, + fontSize = settings.fontSize.lastValue.sp, + lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp, + letterSpacing = (settings.letterSpacing.lastValue / 100f).em, + paragraphIndentation = paragraphIndentation, + ) + org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke( + text = state.value.text, + contentWidthPx = contentWidthPx, + contentHeightPx = contentHeightPx, + paragraphStyle = style, + chapterTitleAlignment = settings.chapterTitleAlignment.lastValue, + paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(), + textMeasurer = textMeasurer, + imageMaxWidthPx = imageMaxWidthPx, + chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(), + separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(), + ) + } + } + } + + val perceptionExpanderPadding = remember( + sidePadding, + settings.perceptionExpanderPadding.value + ) { + sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp + } + val perceptionExpanderThickness = remember( + settings.perceptionExpanderThickness.value + ) { + (settings.perceptionExpanderThickness.lastValue * 0.25f).dp + } + val horizontalLimiterHeight = remember( + settings.horizontalLimiterHeight.value + ) { + (settings.horizontalLimiterHeight.lastValue * 20).dp + } + val horizontalLimiterRulerThickness = remember( + settings.horizontalLimiterRulerThickness.value + ) { + (settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp + } + val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) { + (36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp + } + val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) { + when (settings.highlightedReadingThickness.lastValue) { + 2 -> FontWeight.SemiBold + 3 -> FontWeight.Bold + else -> FontWeight.Medium + } + } + val horizontalAlignment = remember(settings.textAlignment.value) { + when (settings.textAlignment.lastValue) { + ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start + ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally + ReaderTextAlignment.END -> Alignment.End + } + } + val imagesCornersRoundness = remember( + settings.imagesCornersRoundness.value, + settings.imagesWidth.value + ) { + (settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp + } + val imagesColorEffects = remember( + settings.imagesColorEffects.value, + fontColor.value, + backgroundColor.value + ) { + when (settings.imagesColorEffects.lastValue) { + ReaderColorEffects.OFF -> null + + ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix( + ColorMatrix().apply { setToSaturation(0f) } + ) + + ReaderColorEffects.FONT -> ColorFilter.tint( + color = fontColor.value, + blendMode = BlendMode.Color + ) + + ReaderColorEffects.BACKGROUND -> ColorFilter.tint( + color = backgroundColor.value, + blendMode = BlendMode.Color + ) + } + } val bottomBarPadding = remember(settings.bottomBarPadding.value) { (settings.bottomBarPadding.lastValue * 4f).dp } @@ -336,6 +438,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { LaunchedEffect(Unit) { screenModel.init(bookId = bookId) } + LaunchedEffect(pages) { + screenModel.onEvent(ReaderEvent.OnPagesComputed(pages)) + } LaunchedEffect(settings.fullscreen.value) { screenModel.onEvent( ReaderEvent.OnMenuVisibility( @@ -372,11 +477,45 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + onDispose { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } + DisposableEffect(readerPagination, state.value.pages, state.value.currentPage) { + if (!readerPagination || state.value.pages.isEmpty()) { + onDispose { } + } else { + val pages = state.value.pages + val currentRef = java.util.concurrent.atomic.AtomicInteger(state.value.currentPage) + val originalCallback = activity.window.callback + val proxy = org.dueattendant149.bookshelf.ui.reader.VolumeKeyWindowCallback( + delegate = originalCallback, + onKeyEvent = { event -> + if (event.action == KeyEvent.ACTION_DOWN) { + val current = currentRef.get() + val target = when (event.keyCode) { + KeyEvent.KEYCODE_VOLUME_UP -> if (current > 0) current - 1 else -1 + KeyEvent.KEYCODE_VOLUME_DOWN -> if (current < pages.lastIndex) current + 1 else -1 + else -> -1 + } + if (target >= 0) { + currentRef.set(target) + screenModel.onEvent(ReaderEvent.OnChangePage(target)) + return@VolumeKeyWindowCallback true + } + } + false + } + ) + activity.window.callback = proxy + + onDispose { + activity.window.callback = originalCallback + } + } + } DisposableEffect(Unit) { onDispose { screenModel.clearAsync() @@ -393,32 +532,44 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { fullscreen = settings.fullscreen.value ) - ReaderContent( - book = state.value.book, - text = state.value.text, - bottomSheet = state.value.bottomSheet, - drawer = state.value.drawer, - listState = listState, - currentChapter = state.value.currentChapter, - nestedScrollConnection = nestedScrollConnection.value, - fastColorPresetChange = settings.fastColorPresetChange.value, - perceptionExpander = settings.perceptionExpander.value, - perceptionExpanderPadding = perceptionExpanderPadding, - perceptionExpanderThickness = perceptionExpanderThickness, - horizontalLimiter = settings.horizontalLimiter.value, - horizontalLimiterHeight = horizontalLimiterHeight, - horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value, - horizontalLimiterRuler = settings.horizontalLimiterRuler.value, - horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, - horizontalLimiterDimming = settings.horizontalLimiterDimming.value, - currentChapterProgress = state.value.currentChapterProgress, - isLoading = state.value.isLoading, - errorMessage = state.value.errorMessage, - checkpoints = state.value.checkpoints, - showMenu = state.value.showMenu, - lockMenu = state.value.lockMenu, - contentPadding = contentPadding, - verticalPadding = verticalPadding, + Box( + modifier = Modifier + .focusable() + ) { + ReaderContent( + book = state.value.book, + text = state.value.text, + bottomSheet = state.value.bottomSheet, + drawer = state.value.drawer, + listState = listState, + currentChapter = state.value.currentChapter, + nestedScrollConnection = nestedScrollConnection.value, + fastColorPresetChange = settings.fastColorPresetChange.value, + perceptionExpander = settings.perceptionExpander.value, + perceptionExpanderPadding = perceptionExpanderPadding, + perceptionExpanderThickness = perceptionExpanderThickness, + horizontalLimiter = settings.horizontalLimiter.value, + horizontalLimiterHeight = horizontalLimiterHeight, + horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value, + horizontalLimiterRuler = settings.horizontalLimiterRuler.value, + horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, + horizontalLimiterDimming = settings.horizontalLimiterDimming.value, + currentChapterProgress = state.value.currentChapterProgress, + isLoading = state.value.isLoading, + errorMessage = state.value.errorMessage, + checkpoints = state.value.checkpoints, + showMenu = state.value.showMenu, + lockMenu = state.value.lockMenu, + readerPagination = readerPagination, + pages = state.value.pages, + currentPage = state.value.currentPage, + onPageChanged = { page -> + if (state.value.pages.isNotEmpty()) { + screenModel.onEvent(ReaderEvent.OnChangePage(page)) + } + }, + contentPadding = contentPadding, + verticalPadding = verticalPadding, horizontalGesture = settings.horizontalGesture.value, horizontalGestureScroll = settings.horizontalGestureScroll.value, horizontalGestureSensitivity = horizontalGestureSensitivity, @@ -470,7 +621,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { showChaptersDrawer = screenModel::onEvent, dismissDrawer = screenModel::onEvent, navigateBack = screenModel::onEvent, - navigateToBookInfo = screenModel::onEvent + navigateToBookInfo = screenModel::onEvent, + navigateToRsvp = screenModel::onEvent ) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt index e46aecd0..60aa8023 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt @@ -12,6 +12,7 @@ import org.dueattendant149.bookshelf.core.BottomSheet import org.dueattendant149.bookshelf.core.Drawer import org.dueattendant149.bookshelf.core.ui.UIText import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage import org.dueattendant149.bookshelf.domain.model.reader.ReaderText import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint @@ -20,6 +21,8 @@ import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint data class ReaderState( val book: Book = Book.default, val text: List = emptyList(), + val pages: List = emptyList(), + val currentPage: Int = 0, val listState: LazyListState = LazyListState(), val currentChapter: Chapter? = null, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt new file mode 100644 index 00000000..5635f394 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt @@ -0,0 +1,11 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.rsvp + +sealed class RsvpEffect { + data object OnNavigateBack : RsvpEffect() +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt new file mode 100644 index 00000000..6ea608be --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt @@ -0,0 +1,17 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.rsvp + +sealed class RsvpEvent { + data object OnPlayPause : RsvpEvent() + data object OnStop : RsvpEvent() + data object OnClose : RsvpEvent() + data class OnWpmChange(val wpm: Int) : RsvpEvent() + data class OnSeekToProgress(val progress: Float) : RsvpEvent() + data class OnSkip(val delta: Int) : RsvpEvent() + data object OnToggleControls : RsvpEvent() +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt new file mode 100644 index 00000000..3f20b981 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt @@ -0,0 +1,159 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.rsvp + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.dueattendant149.bookshelf.data.settings.SettingsManager +import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpEngine +import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpTokenizer +import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase +import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase +import javax.inject.Inject +import kotlin.math.roundToInt + +@HiltViewModel +class RsvpModel + @Inject + constructor( + private val getBookUseCase: GetBookUseCase, + private val getTextUseCase: GetTextUseCase, + private val updateBookUseCase: UpdateBookUseCase, + private val settings: SettingsManager, + ) : ViewModel() { + + private val engine = RsvpEngine(viewModelScope) + + private val _state = MutableStateFlow(RsvpState()) + val state = _state.asStateFlow() + + private val _effects = MutableSharedFlow() + val effects = _effects.asSharedFlow() + + val engineState = engine.state + + fun init(bookId: Int) { + viewModelScope.launch(Dispatchers.Default) { + val book = getBookUseCase(bookId) + if (book == null) { + _state.update { it.copy(isLoading = false, errorMessage = "Book not found") } + _effects.emit(RsvpEffect.OnNavigateBack) + return@launch + } + + _state.update { it.copy(book = book, isLoading = true, errorMessage = null) } + + val text = getTextUseCase(bookId) + if (text.isEmpty()) { + _state.update { + it.copy( + isLoading = false, + errorMessage = "Текст недоступен" + ) + } + return@launch + } + + val tokens = withContext(Dispatchers.Default) { + RsvpTokenizer.tokenizeReaderText(text) + } + + if (tokens.isEmpty()) { + _state.update { + it.copy(isLoading = false, errorMessage = "Книга пуста") + } + return@launch + } + + val startWpm = settings.rsvpWpm.lastValue.takeIf { it in 50..1500 } ?: 350 + val pauseOnParagraph = settings.rsvpPauseOnParagraph.lastValue + val pauseOnChapter = settings.rsvpPauseOnChapter.lastValue + val pauseOnLong = settings.rsvpPauseOnLongWords.lastValue + + engine.setWpm(startWpm) + engine.setPauseOnParagraphEnd(pauseOnParagraph) + engine.setPauseOnChapterEnd(pauseOnChapter) + engine.setPauseOnLongWords(pauseOnLong) + engine.setTokens(tokens) + + val startIndex = (book.progress * (tokens.lastIndex.coerceAtLeast(1))) + .roundToInt() + .coerceIn(0, tokens.lastIndex) + engine.seekToIndex(startIndex) + + _state.update { + it.copy( + book = book, + isLoading = false, + errorMessage = null, + ) + } + } + } + + fun onEvent(event: RsvpEvent) { + when (event) { + RsvpEvent.OnPlayPause -> engine.toggle() + RsvpEvent.OnStop -> engine.stop() + RsvpEvent.OnClose -> { + persistProgressAndClose() + } + + is RsvpEvent.OnWpmChange -> { + val clamped = event.wpm.coerceIn(50, 1500) + engine.setWpm(clamped) + settings.rsvpWpm.update(clamped) + } + + is RsvpEvent.OnSeekToProgress -> { + engine.seekToProgress(event.progress) + } + + is RsvpEvent.OnSkip -> { + if (event.delta >= 0) engine.skipForward(event.delta) + else engine.skipBackward(-event.delta) + } + + RsvpEvent.OnToggleControls -> { + _state.update { it.copy(showControls = !it.showControls) } + } + } + } + + private fun persistProgressAndClose() { + viewModelScope.launch { + val es = engine.state.value + val currentBook = state.value.book + if (es.tokens.isNotEmpty() && currentBook.id != 0) { + val newProgress = es.currentIndex.toFloat() / + es.tokens.lastIndex.coerceAtLeast(1).toFloat() + updateBookUseCase( + currentBook.copy( + progress = newProgress.coerceIn(0f, 1f), + ) + ) + } + engine.stop() + _effects.emit(RsvpEffect.OnNavigateBack) + } + } + + override fun onCleared() { + super.onCleared() + engine.stop() + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt new file mode 100644 index 00000000..c5fa954d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt @@ -0,0 +1,41 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.rsvp + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.collectLatest +import kotlinx.parcelize.Parcelize +import org.dueattendant149.bookshelf.presentation.navigator.Screen +import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator +import org.dueattendant149.bookshelf.ui.rsvp.RsvpContent + +@Parcelize +data class RsvpScreen(val bookId: Int) : Screen, Parcelable { + + @Composable + override fun Content() { + val model = hiltViewModel() + val navigator = LocalNavigator.current + + LaunchedEffect(bookId) { + model.init(bookId) + } + LaunchedEffect(model.effects, navigator) { + model.effects.collectLatest { effect -> + when (effect) { + RsvpEffect.OnNavigateBack -> navigator.pop() + } + } + } + + RsvpContent(model = model) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt new file mode 100644 index 00000000..52d2ee84 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt @@ -0,0 +1,18 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.rsvp + +import androidx.compose.runtime.Immutable +import org.dueattendant149.bookshelf.domain.model.library.Book + +@Immutable +data class RsvpState( + val book: Book = Book.default, + val isLoading: Boolean = true, + val errorMessage: String? = null, + val showControls: Boolean = true, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt new file mode 100644 index 00000000..1237272d --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt @@ -0,0 +1,146 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse +import org.dueattendant149.bookshelf.domain.use_case.remote.GlobalSearchUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.ListDownloadsUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.StartDownloadUseCase +import org.dueattendant149.bookshelf.domain.use_case.remote.UploadBookUseCase +import java.io.File +import javax.inject.Inject + +data class SearchState( + val query: String = "", + val results: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val downloadingTitle: String? = null, + val downloadSuccess: String? = null, + val downloadProgresses: Map = emptyMap(), + val uploadSuccess: String? = null, + val isUploading: Boolean = false, +) + +@HiltViewModel +class SearchModel + @Inject + constructor( + private val globalSearchUseCase: GlobalSearchUseCase, + private val startDownloadUseCase: StartDownloadUseCase, + private val listDownloadsUseCase: ListDownloadsUseCase, + private val uploadBookUseCase: UploadBookUseCase, + ) : ViewModel() { + private val _state = MutableStateFlow(SearchState()) + val state = _state.asStateFlow() + + private var pollJob: Job? = null + + fun setQuery(query: String) { + _state.update { it.copy(query = query) } + } + + fun search() { + val query = state.value.query.trim() + if (query.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, error = null, results = emptyList()) } + val result = globalSearchUseCase(SearchRequest(query = query)) + _state.update { + it.copy( + isLoading = false, + results = result.getOrDefault(SearchResponse(emptyList())).results, + error = result.exceptionOrNull()?.message, + ) + } + } + } + + fun startDownload(item: SearchResultItemResponse) { + viewModelScope.launch { + _state.update { it.copy(downloadingTitle = item.title, downloadSuccess = null) } + val request = DownloadRequest( + source = item.source, + title = item.title, + author = item.author, + downloadUrl = item.downloadUrl.ifBlank { null }, + magnetUrl = item.magnetUrl.ifBlank { null }, + infoHash = item.infoHash.ifBlank { null }, + md5 = item.md5.ifBlank { null }, + url = item.url.ifBlank { null }, + mediaType = item.mediaType.ifBlank { "ebook" }, + downloadProtocol = item.downloadProtocol, + ) + val result = startDownloadUseCase(request) + _state.update { + it.copy( + downloadingTitle = null, + downloadSuccess = if (result.isSuccess) item.title else null, + error = result.exceptionOrNull()?.message, + ) + } + if (result.isSuccess) { + startPolling() + } + } + } + + fun uploadBook(file: File, bookType: String) { + viewModelScope.launch { + _state.update { it.copy(isUploading = true, uploadSuccess = null, error = null) } + val result = uploadBookUseCase(file, bookType) + _state.update { + it.copy( + isUploading = false, + uploadSuccess = result.getOrNull(), + error = result.exceptionOrNull()?.message, + ) + } + } + } + + fun clearUploadSuccess() { + _state.update { it.copy(uploadSuccess = null) } + } + + fun clearDownloadSuccess() { + _state.update { it.copy(downloadSuccess = null) } + } + + private fun startPolling() { + pollJob?.cancel() + pollJob = viewModelScope.launch { + while (true) { + delay(3000L) + val result = listDownloadsUseCase() + result.onSuccess { list -> + val map = list.downloads.associateBy { it.title } + _state.update { it.copy(downloadProgresses = map) } + } + } + } + } + + override fun onCleared() { + super.onCleared() + pollJob?.cancel() + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt new file mode 100644 index 00000000..61847a64 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt @@ -0,0 +1,24 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.presentation.search + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.parcelize.Parcelize +import org.dueattendant149.bookshelf.presentation.navigator.Screen +import org.dueattendant149.bookshelf.ui.search.SearchContent + +@Parcelize +object SearchScreen : Screen, Parcelable { + + @Composable + override fun Content() { + val model = hiltViewModel() + SearchContent(model = model) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt index 8fd8afcf..f25ed13b 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt @@ -10,6 +10,7 @@ import android.os.Parcelable import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.parcelize.Parcelize import org.dueattendant149.bookshelf.presentation.navigator.Screen import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen @@ -29,9 +30,12 @@ object SettingsScreen : Screen, Parcelable { val settings = LocalSettings.current val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior() + val isRoot = navigator.items.collectAsStateWithLifecycle().value.size <= 1 + SettingsContent( listState = listState, scrollBehavior = scrollBehavior, + isRoot = isRoot, navigateToGeneralSettings = { navigator.push(GeneralSettingsScreen) }, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt index bbb844c8..87b3b8d3 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt @@ -8,36 +8,25 @@ package org.dueattendant149.bookshelf.ui.bookshelf import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudQueue -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.material3.Button -import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -51,39 +40,62 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.domain.model.cache.CacheState -import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus import org.dueattendant149.bookshelf.domain.model.library.Book -import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel +import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel +import org.dueattendant149.bookshelf.ui.common.components.book.BookCard +import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton +import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder +import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator +import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable fun RemoteLibraryContent( - model: RemoteLibraryModel, + model: RemoteLibraryViewModel, + titleRes: Int, onPlayAudio: (Book) -> Unit, navigateToTts: (bookRemoteId: String) -> Unit, ) { val state by model.state.collectAsStateWithLifecycle() + val navigator = LocalNavigator.current val refreshState = rememberPullRefreshState( refreshing = state.isLoading, onRefresh = model::loadLibraries ) - val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) { - if (!state.offlineOnly) state.books - else state.books.filter { book -> - val status = state.cacheStatuses[book.remoteId] - status?.state == CacheState.COMPLETED || - status?.state == CacheState.DOWNLOADING || - status?.state == CacheState.PENDING + val sortedBooks = remember(state.books) { + state.books.sortedByDescending { it.lastOpened ?: 0L } + } + + val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) { + if (!state.offlineOnly) sortedBooks + else sortedBooks.filter { book -> + when (state.cacheStatuses[book.remoteId]?.state) { + CacheState.COMPLETED, + CacheState.DOWNLOADING, + CacheState.PENDING -> true + else -> false + } } } + val onlineIndicator = remember(state.serverOnline) { + if (state.serverOnline) "" + else " • Offline" + } + Scaffold( + contentWindowInsets = WindowInsets(0), topBar = { TopAppBar( - title = { Text(stringResource(R.string.bookshelf_screen)) } + title = { + Text( + text = stringResource(titleRes) + onlineIndicator, + style = MaterialTheme.typography.titleLarge + ) + } ) } ) { padding -> @@ -98,97 +110,71 @@ fun RemoteLibraryContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - item { - Text( - text = stringResource(R.string.bookshelf_libraries_header), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 4.dp) - ) - } - - items(state.libraries.size) { index -> - val library = state.libraries[index] - val selected = state.selectedLibrary?.id == library.id - Card( - onClick = { model.selectLibrary(library) }, - modifier = Modifier.fillMaxWidth(), - ) { - ListItem( - headlineContent = { - Text( - text = library.name, - style = MaterialTheme.typography.titleMedium - ) - }, - supportingContent = { - Text( - text = stringResource( - R.string.bookshelf_library_subtitle, - library.mediaType, - library.itemCount - ) - ) - }, - trailingContent = { - if (selected) { - Text( - text = stringResource(R.string.bookshelf_selected_label), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - ) - } - } - - state.selectedLibrary?.let { library -> - item { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.bookshelf_books_header, library.name), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 4.dp) - ) - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.bookshelf_offline_only_label), - style = MaterialTheme.typography.bodyMedium - ) - Switch( - checked = state.offlineOnly, - onCheckedChange = { model.toggleOfflineOnly() } - ) - } - } - } - - items(visibleBooks.size) { index -> - val book = visibleBooks[index] + items( + items = visibleBooks, + key = { it.remoteId } + ) { book -> val cacheStatus = state.cacheStatuses[book.remoteId] val isCaching = state.cachingBookId == book.remoteId - BookListItem( + + val progress = when { + book.hasAudio -> { + val dur = book.audioDuration.coerceAtLeast(1L) + (book.audioCurrentPosition.toFloat() / dur).coerceIn(0f, 1f) + } + else -> book.progress.coerceIn(0f, 1f) + } + + val primaryLabel: String + val secondaryLabel: String + val onPrimaryClick: () -> Unit + val onSecondaryClick: () -> Unit + + when { + book.hasAudio -> { + primaryLabel = stringResource(R.string.bookshelf_play_audio_action) + secondaryLabel = stringResource(R.string.chapters_button) + onPrimaryClick = { onPlayAudio(book) } + onSecondaryClick = { navigator.push(ChapterListScreen(book)) } + } + book.hasEbook -> { + primaryLabel = stringResource(R.string.read) + secondaryLabel = stringResource(R.string.tts_button) + onPrimaryClick = { model.openBook(book) } + onSecondaryClick = { navigateToTts(book.remoteId) } + } + else -> { + primaryLabel = "" + secondaryLabel = "" + onPrimaryClick = {} + onSecondaryClick = {} + } + } + + BookCard( book = book, - cacheStatus = cacheStatus, - isCaching = isCaching, - onDownload = { model.cacheBook(book) }, - onDelete = { model.deleteCache(book) }, - onRead = if (book.hasEbook) ({ model.openBook(book) }) else null, - onPlayAudio = if (book.hasAudio) ({ onPlayAudio(book) }) else null, - onTts = { navigateToTts(book.remoteId) } + progress = progress, + primaryButton = { + if (primaryLabel.isNotBlank()) { + PrimaryActionButton( + label = primaryLabel, + enabled = !isCaching, + onClick = onPrimaryClick + ) + } + }, + secondaryButton = { + if (secondaryLabel.isNotBlank()) { + SecondaryActionButton( + label = secondaryLabel, + onClick = onSecondaryClick + ) + } + } ) } - if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) { + if (state.isLoading && state.books.isEmpty()) { item { Box( modifier = Modifier @@ -202,7 +188,7 @@ fun RemoteLibraryContent( } } - if (!state.isLoading && state.libraries.isEmpty() && state.error == null) { + if (!state.isLoading && state.books.isEmpty() && state.error == null) { EmptyPlaceholder( modifier = Modifier.align(Alignment.Center), message = stringResource(R.string.bookshelf_empty_message), @@ -212,6 +198,7 @@ fun RemoteLibraryContent( ) } + state.error?.let { error -> ErrorPlaceholder( modifier = Modifier.align(Alignment.Center), @@ -233,122 +220,4 @@ fun RemoteLibraryContent( } } -@Composable -private fun BookListItem( - book: Book, - cacheStatus: CacheStatus?, - isCaching: Boolean, - onDownload: () -> Unit, - onDelete: () -> Unit, - onRead: (() -> Unit)?, - onPlayAudio: (() -> Unit)?, - onTts: () -> Unit, -) { - val cacheState = cacheStatus?.state ?: CacheState.NONE - val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED - val showDelete = cacheState != CacheState.NONE - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column { - ListItem( - headlineContent = { - Text( - text = book.title, - style = MaterialTheme.typography.titleMedium - ) - }, - supportingContent = { - Column { - Text( - text = book.author.getAsString() - ?: stringResource(R.string.unknown_author) - ) - if (cacheState != CacheState.NONE) { - Text( - text = cacheStatusLabel(cacheState), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary - ) - } - } - }, - trailingContent = { - Row { - if (book.hasAudio) { - IconButton(onClick = onPlayAudio ?: {}) { - Icon( - imageVector = Icons.Default.PlayArrow, - contentDescription = stringResource(R.string.bookshelf_play_audio_action) - ) - } - } - if (showDownload) { - IconButton(onClick = onDownload) { - Icon( - imageVector = Icons.Default.Download, - contentDescription = stringResource(R.string.bookshelf_download_action) - ) - } - } - if (showDelete) { - IconButton(onClick = onDelete) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.bookshelf_delete_cache_action) - ) - } - } - } - } - ) - - if (cacheState == CacheState.DOWNLOADING) { - LinearProgressIndicator( - progress = { cacheStatus?.progress?.coerceIn(0f, 1f) ?: 0f }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - } - - if (onRead != null || onPlayAudio != null) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) - ) { - Button(onClick = onTts) { - Text(stringResource(R.string.tts_button)) - } - onRead?.let { read -> - Button( - onClick = read, - enabled = !isCaching - ) { - Text(stringResource(R.string.read)) - } - } - onPlayAudio?.let { play -> - Button(onClick = play) { - Text(stringResource(R.string.bookshelf_play_audio_action)) - } - } - } - } - } - } -} - -@Composable -private fun cacheStatusLabel(state: CacheState): String = - when (state) { - CacheState.NONE -> "" - CacheState.PENDING -> stringResource(R.string.bookshelf_cache_pending) - CacheState.DOWNLOADING -> stringResource(R.string.bookshelf_cache_downloading) - CacheState.COMPLETED -> stringResource(R.string.bookshelf_cache_completed) - CacheState.FAILED -> stringResource(R.string.bookshelf_cache_failed) - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt new file mode 100644 index 00000000..05d6e94c --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt @@ -0,0 +1,184 @@ +package org.dueattendant149.bookshelf.ui.common.components.book + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.domain.model.library.Book + +@Composable +fun BookCard( + book: Book, + progress: Float, + primaryButton: @Composable (() -> Unit)? = null, + secondaryButton: @Composable (() -> Unit)? = null, + coverBadge: (@Composable () -> Unit)? = null, + modifier: Modifier = Modifier, +) { + val shape = RoundedCornerShape(16.dp) + val coercedProgress = progress.coerceIn(0f, 1f) + Card( + modifier = modifier + .fillMaxWidth() + .shadow(elevation = 4.dp, shape = shape, clip = false), + shape = shape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Fixed-height vertical cover on the left + Box( + modifier = Modifier + .height(120.dp) + .aspectRatio(2f / 3f) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + if (book.coverUrl.isNotBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(Uri.parse(book.coverUrl)) + .crossfade(200) + .build(), + contentDescription = stringResource(R.string.cover_image_content_desc), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } else { + CoverPlaceholder(book = book) + } + coverBadge?.invoke() + } + + Spacer(Modifier.width(12.dp)) + + // Title / author / progress in the middle + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically) + ) { + Text( + text = book.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = book.author.getAsString() ?: stringResource(R.string.unknown_author), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.weight(1f)) + + LinearProgressIndicator( + progress = { coercedProgress }, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + Spacer(Modifier.width(12.dp)) + + // Two pill buttons stacked vertically on the right + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + secondaryButton?.invoke() + primaryButton?.invoke() + } + } + } +} + +@Composable +private fun CoverPlaceholder(book: Book) { + val seed = remember(book.title, book.author.getAsString()) { + book.title + book.author.getAsString() + } + val color = rememberCoverColor(seed = seed) + Box( + modifier = Modifier + .fillMaxSize() + .background(color), + contentAlignment = Alignment.Center + ) { + Text( + text = book.title.take(1).uppercase(), + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + fontWeight = FontWeight.Bold, + ) + } +} + +@Composable +private fun rememberCoverColor(seed: String): Color { + val palette = remember { + listOf( + Color(0xFF1E88E5), + Color(0xFF43A047), + Color(0xFFE53935), + Color(0xFF8E24AA), + Color(0xFFFDD835), + Color(0xFF00897B), + Color(0xFF3949AB), + Color(0xFFD81B60), + ) + } + val index = (seed.hashCode() % palette.size).let { if (it < 0) it + palette.size else it } + return palette[index] +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt new file mode 100644 index 00000000..b5cdce77 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt @@ -0,0 +1,56 @@ +package org.dueattendant149.bookshelf.ui.common.components.book + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PrimaryActionButton( + label: String, + onClick: () -> Unit, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(50), + modifier = Modifier + .widthIn(min = 84.dp) + .height(40.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ) + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } +} + +@Composable +fun SecondaryActionButton( + label: String, + onClick: () -> Unit, + enabled: Boolean = true, +) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(50), + modifier = Modifier + .widthIn(min = 84.dp) + .height(40.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + ) { + Text(label, style = MaterialTheme.typography.labelLarge) + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt index 29b38fbb..bc70b256 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SkipNext @@ -28,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider @@ -63,6 +65,8 @@ fun PlayerContent( onSkipPrevious: () -> Unit, onSetSpeed: (Float) -> Unit, onSelectTrack: (Int) -> Unit, + onOpenChapters: () -> Unit, + onDownloadAudiobook: () -> Unit, ) { val playerState by state.collectAsStateWithLifecycle() val navigator = LocalNavigator.current @@ -121,9 +125,10 @@ fun PlayerContent( Spacer(modifier = Modifier.height(8.dp)) val duration = playerState.durationMs.coerceAtLeast(1L) - var sliderPosition by remember(playerState.currentPositionMs) { - mutableFloatStateOf(playerState.currentPositionMs / duration.toFloat()) + val positionFraction = remember(playerState.currentPositionMs, duration) { + (playerState.currentPositionMs / duration.toFloat()).coerceIn(0f, 1f) } + var sliderPosition by remember(positionFraction) { mutableFloatStateOf(positionFraction) } Slider( value = sliderPosition, onValueChange = { sliderPosition = it }, @@ -192,13 +197,50 @@ fun PlayerContent( } } + IconButton( + onClick = onOpenChapters, + modifier = Modifier.fillMaxWidth() + ) { + Text("Главы") + } + + IconButton( + onClick = onDownloadAudiobook, + enabled = !playerState.isCaching, + modifier = Modifier.fillMaxWidth() + ) { + if (playerState.isCaching) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Spacer(Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { playerState.cacheProgress.coerceIn(0f, 1f) }, + modifier = Modifier + .fillMaxWidth(0.6f) + .height(4.dp), + ) + } + } else { + Icon( + imageVector = Icons.Default.CloudDownload, + contentDescription = "Скачать аудиокнигу" + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) LazyColumn( modifier = Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - itemsIndexed(playerState.tracks) { index, track -> + itemsIndexed( + items = playerState.tracks, + key = { _, track -> track.fileId } + ) { index, track -> val selected = index == playerState.currentTrackIndex Card( onClick = { onSelectTrack(index) }, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt index 214f0847..d43a0d6f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt @@ -50,6 +50,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable import org.dueattendant149.bookshelf.ui.common.model.Direction import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition import org.dueattendant149.bookshelf.ui.theme.readerBarsColor +import kotlin.math.roundToInt @Composable fun ReaderBottomBar( @@ -60,6 +61,10 @@ fun ReaderBottomBar( lockMenu: Boolean, checkpoints: List, bottomBarPadding: Dp, + readerPagination: Boolean, + pages: List, + currentPage: Int, + onChangePage: (Int) -> Unit, restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit, scroll: (ReaderEvent.OnScroll) -> Unit, changeProgress: (ReaderEvent.OnChangeProgress) -> Unit @@ -138,13 +143,17 @@ fun ReaderBottomBar( } }, slider = { - ReaderBottomBarSlider( - book = book, - lockMenu = lockMenu, - listState = listState, - scroll = scroll, - changeProgress = changeProgress - ) + ReaderBottomBarSlider( + book = book, + lockMenu = lockMenu, + listState = listState, + readerPagination = readerPagination, + pages = pages, + currentPage = currentPage, + onChangePage = onChangePage, + scroll = scroll, + changeProgress = changeProgress + ) }, indicator = { ReaderBottomBarCheckpointsIndicator( @@ -240,6 +249,10 @@ private fun ReaderBottomBarSlider( book: Book, lockMenu: Boolean, listState: LazyListState, + readerPagination: Boolean, + pages: List, + currentPage: Int, + onChangePage: (Int) -> Unit, scroll: (ReaderEvent.OnScroll) -> Unit, changeProgress: (ReaderEvent.OnChangeProgress) -> Unit ) { @@ -255,7 +268,17 @@ private fun ReaderBottomBarSlider( value = progress, enabled = !lockMenu, onValueChange = { - if (listState.layoutInfo.totalItemsCount > 0) { + if (readerPagination && pages.isNotEmpty()) { + val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex) + onChangePage(page) + changeProgress( + ReaderEvent.OnChangeProgress( + progress = it, + firstVisibleItemIndex = pages[page].startTextIndex, + firstVisibleItemOffset = 0 + ) + ) + } else if (listState.layoutInfo.totalItemsCount > 0) { scroll(ReaderEvent.OnScroll(it)) changeProgress( ReaderEvent.OnChangeProgress( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt index 64a38678..f05326e4 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt @@ -58,6 +58,10 @@ fun ReaderContent( checkpoints: List, showMenu: Boolean, lockMenu: Boolean, + readerPagination: Boolean, + pages: List, + currentPage: Int, + onPageChanged: (Int) -> Unit, contentPadding: PaddingValues, verticalPadding: Dp, horizontalGesture: ReaderHorizontalGesture, @@ -111,6 +115,7 @@ fun ReaderContent( showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, + navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit, navigateBack: (ReaderEvent.OnNavigateBack) -> Unit ) { ReaderBottomSheet( @@ -120,29 +125,33 @@ fun ReaderContent( ) if (isLoading || errorMessage == null) { - ReaderScaffold( - book = book, - text = text, - listState = listState, - currentChapter = currentChapter, - nestedScrollConnection = nestedScrollConnection, - fastColorPresetChange = fastColorPresetChange, - perceptionExpander = perceptionExpander, - perceptionExpanderPadding = perceptionExpanderPadding, - perceptionExpanderThickness = perceptionExpanderThickness, - horizontalLimiter = horizontalLimiter, - horizontalLimiterHeight = horizontalLimiterHeight, - horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset, - horizontalLimiterRuler = horizontalLimiterRuler, - horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, - horizontalLimiterDimming = horizontalLimiterDimming, - currentChapterProgress = currentChapterProgress, - isLoading = isLoading, - checkpoints = checkpoints, - showMenu = showMenu, - lockMenu = lockMenu, - contentPadding = contentPadding, - verticalPadding = verticalPadding, + ReaderScaffold( + book = book, + text = text, + listState = listState, + currentChapter = currentChapter, + nestedScrollConnection = nestedScrollConnection, + fastColorPresetChange = fastColorPresetChange, + perceptionExpander = perceptionExpander, + perceptionExpanderPadding = perceptionExpanderPadding, + perceptionExpanderThickness = perceptionExpanderThickness, + horizontalLimiter = horizontalLimiter, + horizontalLimiterHeight = horizontalLimiterHeight, + horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset, + horizontalLimiterRuler = horizontalLimiterRuler, + horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, + horizontalLimiterDimming = horizontalLimiterDimming, + currentChapterProgress = currentChapterProgress, + isLoading = isLoading, + checkpoints = checkpoints, + showMenu = showMenu, + lockMenu = lockMenu, + readerPagination = readerPagination, + pages = pages, + currentPage = currentPage, + onPageChanged = onPageChanged, + contentPadding = contentPadding, + verticalPadding = verticalPadding, horizontalGesture = horizontalGesture, horizontalGestureScroll = horizontalGestureScroll, horizontalGestureSensitivity = horizontalGestureSensitivity, @@ -191,7 +200,8 @@ fun ReaderContent( showSettingsBottomSheet = showSettingsBottomSheet, showChaptersDrawer = showChaptersDrawer, navigateBack = navigateBack, - navigateToBookInfo = navigateToBookInfo + navigateToBookInfo = navigateToBookInfo, + navigateToRsvp = navigateToRsvp ) } else { ReaderErrorPlaceholder( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt index 1527bc94..f1efb53b 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt @@ -19,6 +19,7 @@ import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen import org.dueattendant149.bookshelf.presentation.reader.ReaderEffect +import org.dueattendant149.bookshelf.presentation.rsvp.RsvpScreen import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness @@ -183,6 +184,10 @@ fun ReaderEffects( saveInBackStack = false ) } + + is ReaderEffect.OnNavigateToRsvp -> { + navigator.push(RsvpScreen(bookId = effect.bookId)) + } } } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt index fd94325f..c13c374d 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt @@ -21,13 +21,18 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.coerceAtLeast @@ -51,6 +56,10 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable fun ReaderLayout( text: List, + pages: List, + currentPage: Int, + readerPagination: Boolean, + onPageChanged: (Int) -> Unit, listState: LazyListState, contentPadding: PaddingValues, verticalPadding: Dp, @@ -97,6 +106,9 @@ fun ReaderLayout( openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit ) { val activity = LocalActivity.current + val density = LocalDensity.current + val textMeasurer = rememberTextMeasurer() + SelectionContainer( onCopyRequested = { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { @@ -139,7 +151,7 @@ fun ReaderLayout( .fillMaxSize() .background(backgroundColor) .then( - if (!isLoading && toolbarHidden) { + if (!isLoading && toolbarHidden && !readerPagination) { Modifier.noRippleClickable( onClick = { menuVisibility( @@ -154,90 +166,140 @@ fun ReaderLayout( ) .padding(contentPadding) .padding(vertical = verticalPadding) - .readerHorizontalGesture( - listState = listState, - horizontalGesture = horizontalGesture, - horizontalGestureScroll = horizontalGestureScroll, - horizontalGestureSensitivity = horizontalGestureSensitivity, - horizontalGestureAlphaAnim = horizontalGestureAlphaAnim, - horizontalGesturePullAnim = horizontalGesturePullAnim, - isLoading = isLoading + .then( + if (!readerPagination) { + Modifier.readerHorizontalGesture( + listState = listState, + horizontalGesture = horizontalGesture, + horizontalGestureScroll = horizontalGestureScroll, + horizontalGestureSensitivity = horizontalGestureSensitivity, + horizontalGestureAlphaAnim = horizontalGestureAlphaAnim, + horizontalGesturePullAnim = horizontalGesturePullAnim, + isLoading = isLoading + ) + } else Modifier ) ) { - LazyColumnWithScrollbar( - state = listState, - enableScrollbar = false, - parentModifier = Modifier.weight(1f), - modifier = Modifier.fillMaxSize(), - userScrollEnabled = !horizontalGestureDisableScrolling, - contentPadding = PaddingValues( - top = (WindowInsets.displayCutout.asPaddingValues() - .calculateTopPadding() + paragraphHeight) - .coerceAtLeast(18.dp), - bottom = (WindowInsets.displayCutout.asPaddingValues() - .calculateBottomPadding() + paragraphHeight) - .coerceAtLeast(18.dp), + if (readerPagination && pages.isNotEmpty()) { + ReaderPaginationLayout( + pages = pages, + currentPage = currentPage, + contentPadding = PaddingValues(0.dp), + verticalPadding = verticalPadding, + highlightedReading = highlightedReading, + highlightedReadingThickness = highlightedReadingThickness, + progress = progress, + progressBar = progressBar, + progressBarPadding = progressBarPadding, + progressBarAlignment = progressBarAlignment, + progressBarFontSize = progressBarFontSize, + paragraphHeight = paragraphHeight, + sidePadding = sidePadding, + bottomBarPadding = 0.dp, + backgroundColor = backgroundColor, + fontColor = fontColor, + images = images, + imagesCaptions = imagesCaptions, + imagesCornersRoundness = imagesCornersRoundness, + imagesAlignment = imagesAlignment, + imagesWidth = imagesWidth, + imagesColorEffects = imagesColorEffects, + fontFamily = fontFamily, + lineHeight = lineHeight, + fontThickness = fontThickness, + fontStyle = fontStyle, + chapterTitleAlignment = chapterTitleAlignment, + textAlignment = textAlignment, + horizontalAlignment = horizontalAlignment, + fontSize = fontSize, + letterSpacing = letterSpacing, + paragraphIndentation = paragraphIndentation, + doubleClickTranslation = doubleClickTranslation, + isLoading = isLoading, + showMenu = showMenu, + menuVisibility = menuVisibility, + openShareApp = openShareApp, + openWebBrowser = openWebBrowser, + openTranslator = openTranslator, + openDictionary = openDictionary, + onPageChanged = onPageChanged ) - ) { - itemsIndexed( - text, - key = { index, _ -> index } - ) { index, entry -> - val previousEntry = text.getOrNull(index - 1) + } else { + LazyColumnWithScrollbar( + state = listState, + enableScrollbar = false, + parentModifier = Modifier.weight(1f), + modifier = Modifier.fillMaxSize(), + userScrollEnabled = !horizontalGestureDisableScrolling, + contentPadding = PaddingValues( + top = (WindowInsets.displayCutout.asPaddingValues() + .calculateTopPadding() + paragraphHeight) + .coerceAtLeast(18.dp), + bottom = (WindowInsets.displayCutout.asPaddingValues() + .calculateBottomPadding() + paragraphHeight) + .coerceAtLeast(18.dp), + ) + ) { + itemsIndexed( + text, + key = { index, _ -> index } + ) { index, entry -> + val previousEntry = text.getOrNull(index - 1) - when { - !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@itemsIndexed - !imagesCaptions && previousEntry is ReaderText.Image -> return@itemsIndexed - else -> { - SpacedItem( - index = index, - spacing = paragraphHeight - ) { - ReaderLayoutText( - showMenu = showMenu, - entry = entry, - imagesCornersRoundness = imagesCornersRoundness, - imagesAlignment = imagesAlignment, - imagesWidth = imagesWidth, - imagesColorEffects = imagesColorEffects, - fontFamily = fontFamily, - fontColor = fontColor, - lineHeight = lineHeight, - fontThickness = fontThickness, - fontStyle = fontStyle, - chapterTitleAlignment = chapterTitleAlignment, - textAlignment = textAlignment, - horizontalAlignment = horizontalAlignment, - fontSize = fontSize, - letterSpacing = letterSpacing, - sidePadding = sidePadding, - paragraphIndentation = paragraphIndentation, - doubleClickTranslation = doubleClickTranslation, - highlightedReading = highlightedReading, - highlightedReadingThickness = highlightedReadingThickness, - toolbarHidden = toolbarHidden, - openTranslator = openTranslator, - menuVisibility = menuVisibility - ) + when { + !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@itemsIndexed + !imagesCaptions && previousEntry is ReaderText.Image -> return@itemsIndexed + else -> { + SpacedItem( + index = index, + spacing = paragraphHeight + ) { + ReaderLayoutText( + showMenu = showMenu, + entry = entry, + imagesCornersRoundness = imagesCornersRoundness, + imagesAlignment = imagesAlignment, + imagesWidth = imagesWidth, + imagesColorEffects = imagesColorEffects, + fontFamily = fontFamily, + fontColor = fontColor, + lineHeight = lineHeight, + fontThickness = fontThickness, + fontStyle = fontStyle, + chapterTitleAlignment = chapterTitleAlignment, + textAlignment = textAlignment, + horizontalAlignment = horizontalAlignment, + fontSize = fontSize, + letterSpacing = letterSpacing, + sidePadding = sidePadding, + paragraphIndentation = paragraphIndentation, + doubleClickTranslation = doubleClickTranslation, + highlightedReading = highlightedReading, + highlightedReadingThickness = highlightedReadingThickness, + toolbarHidden = toolbarHidden, + openTranslator = openTranslator, + menuVisibility = menuVisibility + ) + } } } } } - } - AnimatedVisibility( - visible = !showMenu && progressBar, - enter = slideInVertically { it } + expandVertically(), - exit = slideOutVertically { it } + shrinkVertically() - ) { - ReaderProgressBar( - progress = progress, - progressBarPadding = progressBarPadding, - progressBarAlignment = progressBarAlignment, - progressBarFontSize = progressBarFontSize, - fontColor = fontColor, - sidePadding = sidePadding - ) + AnimatedVisibility( + visible = !showMenu && progressBar, + enter = slideInVertically { it } + expandVertically(), + exit = slideOutVertically { it } + shrinkVertically() + ) { + ReaderProgressBar( + progress = progress, + progressBarPadding = progressBarPadding, + progressBarAlignment = progressBarAlignment, + progressBarFontSize = progressBarFontSize, + fontColor = fontColor, + sidePadding = sidePadding + ) + } } } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt index 808646bf..705cec4f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt @@ -6,7 +6,6 @@ package org.dueattendant149.bookshelf.ui.reader -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color @@ -23,7 +22,7 @@ import org.dueattendant149.bookshelf.ui.reader.model.FontWithName import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable -fun LazyItemScope.ReaderLayoutText( +fun ReaderLayoutText( showMenu: Boolean, entry: ReaderText, imagesCornersRoundness: Dp, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt index c34f99ed..b2d24f87 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -26,7 +25,7 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme import org.dueattendant149.bookshelf.ui.common.components.common.StyledText @Composable -fun LazyItemScope.ReaderLayoutTextChapter( +fun ReaderLayoutTextChapter( chapter: Chapter, chapterTitleAlignment: ReaderTextAlignment, fontColor: Color, @@ -36,10 +35,6 @@ fun LazyItemScope.ReaderLayoutTextChapter( ) { Column( Modifier - .animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) .fillMaxWidth() ) { Spacer(modifier = Modifier.height(22.dp)) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt index f6d5b1e9..2fccc005 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -22,7 +21,7 @@ import org.dueattendant149.bookshelf.domain.model.reader.ReaderText import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable -fun LazyItemScope.ReaderLayoutTextImage( +fun ReaderLayoutTextImage( entry: ReaderText.Image, sidePadding: Dp, imagesCornersRoundness: Dp, @@ -32,10 +31,6 @@ fun LazyItemScope.ReaderLayoutTextImage( ) { Box( modifier = Modifier - .animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) .padding(horizontal = sidePadding) .fillMaxWidth(), contentAlignment = imagesAlignment.alignment diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt index 1320c793..c69f4bbd 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt @@ -10,16 +10,12 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.LineBreak -import androidx.compose.ui.text.style.TextIndent import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Text @@ -29,9 +25,10 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme import org.dueattendant149.bookshelf.ui.common.components.common.StyledText import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable import org.dueattendant149.bookshelf.ui.reader.model.FontWithName +import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle @Composable -fun LazyItemScope.ReaderLayoutTextParagraph( +fun ReaderLayoutTextParagraph( paragraph: Text, showMenu: Boolean, fontFamily: FontWithName, @@ -52,9 +49,20 @@ fun LazyItemScope.ReaderLayoutTextParagraph( openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit, menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit ) { + val style = readerParagraphTextStyle( + fontFamily = fontFamily, + fontThickness = fontThickness, + fontStyle = fontStyle, + textAlignment = textAlignment, + fontSize = fontSize, + lineHeight = lineHeight, + letterSpacing = letterSpacing, + paragraphIndentation = paragraphIndentation, + color = fontColor, + ) + Column( modifier = Modifier - .animateItem(fadeInSpec = null, fadeOutSpec = null) .fillMaxWidth() .padding(horizontal = sidePadding), verticalArrangement = Arrangement.Center, @@ -84,18 +92,7 @@ fun LazyItemScope.ReaderLayoutTextParagraph( ) } else Modifier ), - style = TextStyle( - fontFamily = fontFamily.font, - fontWeight = fontThickness.thickness, - textAlign = textAlignment.textAlignment, - textIndent = TextIndent(firstLine = paragraphIndentation), - fontStyle = fontStyle, - letterSpacing = letterSpacing, - fontSize = fontSize, - lineHeight = lineHeight, - color = fontColor, - lineBreak = LineBreak.Paragraph - ), + style = style, highlightText = highlightedReading, highlightThickness = highlightedReadingThickness ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt index be3220ee..e630de25 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt @@ -7,7 +7,6 @@ package org.dueattendant149.bookshelf.ui.reader import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable @@ -18,17 +17,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable -fun LazyItemScope.ReaderLayoutTextSeparator( +fun ReaderLayoutTextSeparator( sidePadding: Dp, fontColor: Color ) { HorizontalDivider( thickness = 3.dp, modifier = Modifier - .animateItem( - fadeInSpec = null, - fadeOutSpec = null - ) .padding(horizontal = sidePadding) .clip(CircleShape), color = fontColor.copy(0.3f) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt new file mode 100644 index 00000000..2fa65363 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt @@ -0,0 +1,276 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.reader + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize.Fill +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage +import org.dueattendant149.bookshelf.domain.model.reader.ReaderText +import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent +import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness +import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment +import org.dueattendant149.bookshelf.ui.common.components.common.SelectionContainer +import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity +import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable +import org.dueattendant149.bookshelf.ui.common.helpers.showToast +import org.dueattendant149.bookshelf.ui.reader.model.FontWithName +import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment + +@Composable +fun ReaderPaginationLayout( + pages: List, + currentPage: Int, + contentPadding: PaddingValues, + verticalPadding: Dp, + highlightedReading: Boolean, + highlightedReadingThickness: FontWeight, + progress: String, + progressBar: Boolean, + progressBarPadding: Dp, + progressBarAlignment: HorizontalAlignment, + progressBarFontSize: TextUnit, + paragraphHeight: Dp, + sidePadding: Dp, + bottomBarPadding: Dp, + backgroundColor: Color, + fontColor: Color, + images: Boolean, + imagesCaptions: Boolean, + imagesCornersRoundness: Dp, + imagesAlignment: HorizontalAlignment, + imagesWidth: Float, + imagesColorEffects: ColorFilter?, + fontFamily: FontWithName, + lineHeight: TextUnit, + fontThickness: ReaderFontThickness, + fontStyle: FontStyle, + chapterTitleAlignment: ReaderTextAlignment, + textAlignment: ReaderTextAlignment, + horizontalAlignment: Alignment.Horizontal, + fontSize: TextUnit, + letterSpacing: TextUnit, + paragraphIndentation: TextUnit, + doubleClickTranslation: Boolean, + isLoading: Boolean, + showMenu: Boolean, + menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit, + openShareApp: (ReaderEvent.OnOpenShareApp) -> Unit, + openWebBrowser: (ReaderEvent.OnOpenWebBrowser) -> Unit, + openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit, + openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit, + onPageChanged: (Int) -> Unit, +) { + if (pages.isEmpty()) return + + val pagerState = rememberPagerState( + initialPage = currentPage.coerceIn(0, pages.lastIndex), + pageCount = { pages.size } + ) + val scope = rememberCoroutineScope() + val activity = LocalActivity.current + + LaunchedEffect(pagerState.currentPage) { + onPageChanged(pagerState.currentPage) + } + + LaunchedEffect(currentPage) { + if (currentPage != pagerState.currentPage && currentPage in pages.indices) { + pagerState.animateScrollToPage(currentPage) + } + } + + SelectionContainer( + onCopyRequested = { + activity.getString(org.dueattendant149.bookshelf.R.string.copied) + .showToast(context = activity, longToast = false) + }, + onShareRequested = { textToShare -> + openShareApp(ReaderEvent.OnOpenShareApp(textToShare = textToShare)) + }, + onWebSearchRequested = { textToSearch -> + openWebBrowser(ReaderEvent.OnOpenWebBrowser(textToSearch = textToSearch)) + }, + onTranslateRequested = { textToTranslate -> + openTranslator( + ReaderEvent.OnOpenTranslator( + textToTranslate = textToTranslate, + translateWholeParagraph = false + ) + ) + }, + onDictionaryRequested = { textToDefine -> + openDictionary(ReaderEvent.OnOpenDictionary(textToDefine = textToDefine)) + } + ) { toolbarHidden -> + Column( + modifier = Modifier + .fillMaxSize() + .background(backgroundColor) + .then( + if (!isLoading && toolbarHidden) { + Modifier.noRippleClickable( + onClick = { + menuVisibility( + ReaderEvent.OnMenuVisibility( + show = !showMenu, + saveCheckpoint = true + ) + ) + } + ) + } else Modifier + ) + .padding(contentPadding) + .padding(vertical = verticalPadding) + ) { + HorizontalPager( + state = pagerState, + pageSize = Fill, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { pageIndex -> + ReaderPageContent( + page = pages[pageIndex], + verticalPadding = paragraphHeight, + sidePadding = sidePadding, + backgroundColor = backgroundColor, + fontColor = fontColor, + images = images, + imagesCornersRoundness = imagesCornersRoundness, + imagesAlignment = imagesAlignment, + imagesWidth = imagesWidth, + imagesColorEffects = imagesColorEffects, + fontFamily = fontFamily, + lineHeight = lineHeight, + fontThickness = fontThickness, + fontStyle = fontStyle, + chapterTitleAlignment = chapterTitleAlignment, + textAlignment = textAlignment, + horizontalAlignment = horizontalAlignment, + fontSize = fontSize, + letterSpacing = letterSpacing, + paragraphIndentation = paragraphIndentation, + highlightedReading = highlightedReading, + highlightedReadingThickness = highlightedReadingThickness, + toolbarHidden = toolbarHidden, + doubleClickTranslation = doubleClickTranslation, + showMenu = showMenu, + openTranslator = openTranslator, + menuVisibility = menuVisibility + ) + } + + if (!showMenu && progressBar) { + ReaderProgressBar( + progress = progress, + progressBarPadding = progressBarPadding, + progressBarAlignment = progressBarAlignment, + progressBarFontSize = progressBarFontSize, + fontColor = fontColor, + sidePadding = sidePadding + ) + } + } + } +} + +@Composable +private fun ReaderPageContent( + page: ReaderPage, + verticalPadding: Dp, + sidePadding: Dp, + backgroundColor: Color, + fontColor: Color, + images: Boolean, + imagesCornersRoundness: Dp, + imagesAlignment: HorizontalAlignment, + imagesWidth: Float, + imagesColorEffects: ColorFilter?, + fontFamily: FontWithName, + lineHeight: TextUnit, + fontThickness: ReaderFontThickness, + fontStyle: FontStyle, + chapterTitleAlignment: ReaderTextAlignment, + textAlignment: ReaderTextAlignment, + horizontalAlignment: Alignment.Horizontal, + fontSize: TextUnit, + letterSpacing: TextUnit, + paragraphIndentation: TextUnit, + highlightedReading: Boolean, + highlightedReadingThickness: FontWeight, + toolbarHidden: Boolean, + doubleClickTranslation: Boolean, + showMenu: Boolean, + openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit, + menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(vertical = verticalPadding, horizontal = sidePadding), + verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top), + horizontalAlignment = horizontalAlignment + ) { + page.items.forEachIndexed { index, entry -> + val previousEntry = page.items.getOrNull(index - 1) + when { + !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@forEachIndexed + !images && previousEntry is ReaderText.Image -> return@forEachIndexed + else -> { + ReaderLayoutText( + showMenu = showMenu, + entry = entry, + imagesCornersRoundness = imagesCornersRoundness, + imagesAlignment = imagesAlignment, + imagesWidth = imagesWidth, + imagesColorEffects = imagesColorEffects, + fontFamily = fontFamily, + fontColor = fontColor, + lineHeight = lineHeight, + fontThickness = fontThickness, + fontStyle = fontStyle, + chapterTitleAlignment = chapterTitleAlignment, + textAlignment = textAlignment, + horizontalAlignment = horizontalAlignment, + fontSize = fontSize, + letterSpacing = letterSpacing, + sidePadding = 0.dp, + paragraphIndentation = paragraphIndentation, + doubleClickTranslation = doubleClickTranslation, + highlightedReading = highlightedReading, + highlightedReadingThickness = highlightedReadingThickness, + toolbarHidden = toolbarHidden, + openTranslator = openTranslator, + menuVisibility = menuVisibility + ) + } + } + } + } +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt index 31bd411e..c5bd2c03 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt @@ -62,8 +62,13 @@ fun ReaderScaffold( checkpoints: List, showMenu: Boolean, lockMenu: Boolean, + readerPagination: Boolean, + pages: List, + currentPage: Int, + onPageChanged: (Int) -> Unit, contentPadding: PaddingValues, verticalPadding: Dp, + bottomBarPagination: Boolean = readerPagination, horizontalGesture: ReaderHorizontalGesture, horizontalGestureScroll: Float, horizontalGestureSensitivity: Dp, @@ -112,6 +117,7 @@ fun ReaderScaffold( showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit, showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, + navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit, navigateBack: (ReaderEvent.OnNavigateBack) -> Unit ) { Scaffold( @@ -137,7 +143,8 @@ fun ReaderScaffold( showSettingsBottomSheet = showSettingsBottomSheet, showChaptersDrawer = showChaptersDrawer, navigateBack = navigateBack, - navigateToBookInfo = navigateToBookInfo + navigateToBookInfo = navigateToBookInfo, + navigateToRsvp = navigateToRsvp ) } }, @@ -156,6 +163,10 @@ fun ReaderScaffold( lockMenu = lockMenu, checkpoints = checkpoints, bottomBarPadding = bottomBarPadding, + readerPagination = readerPagination, + pages = pages, + currentPage = currentPage, + onChangePage = onPageChanged, restoreCheckpoint = restoreCheckpoint, scroll = scroll, changeProgress = changeProgress @@ -165,6 +176,10 @@ fun ReaderScaffold( ) { ReaderLayout( text = text, + pages = pages, + currentPage = currentPage, + readerPagination = readerPagination, + onPageChanged = onPageChanged, listState = listState, contentPadding = contentPadding, verticalPadding = verticalPadding, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt new file mode 100644 index 00000000..7d0ce7ac --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt @@ -0,0 +1,80 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.reader + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextIndent +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness +import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment +import org.dueattendant149.bookshelf.ui.reader.model.FontWithName + +/** + * Builds a [TextStyle] that is used both for measuring text during pagination + * and for rendering paragraphs in the reader. Keeping the two in sync is + * essential; any difference will cause page breaks to drift from the actual + * rendered layout. + */ +fun readerParagraphTextStyle( + fontFamily: FontWithName, + fontThickness: ReaderFontThickness, + fontStyle: FontStyle, + textAlignment: ReaderTextAlignment, + fontSize: TextUnit, + lineHeight: TextUnit, + letterSpacing: TextUnit, + paragraphIndentation: TextUnit, + color: Color = Color.Unspecified, + lineBreak: LineBreak = LineBreak.Paragraph, + includeFontPadding: Boolean = false, +): TextStyle { + return TextStyle( + fontFamily = fontFamily.font, + fontWeight = fontThickness.thickness, + textAlign = textAlignment.textAlignment, + textIndent = TextIndent(firstLine = paragraphIndentation), + fontStyle = fontStyle, + letterSpacing = letterSpacing, + fontSize = fontSize, + lineHeight = lineHeight, + color = color, + lineBreak = lineBreak, + platformStyle = PlatformTextStyle(includeFontPadding = includeFontPadding), + ) +} + +/** + * Builds a [TextStyle] for measuring chapter titles. It intentionally mirrors + * the rendering style used by [ReaderLayoutTextChapter] as closely as possible + * without accessing MaterialTheme at the call site. + */ +fun readerChapterTextStyle( + nested: Boolean, + textAlignment: ReaderTextAlignment, + color: Color = Color.Unspecified, +): TextStyle { + // Mirror Material3 headlineMedium / headlineSmall sizing. These are the + // default Material3 values; they may differ slightly from a customized + // theme, but they are accurate enough for pagination. + val fontSize = if (nested) 24.sp else 28.sp + val lineHeight = if (nested) 32.sp else 36.sp + return TextStyle( + fontSize = fontSize, + lineHeight = lineHeight, + fontWeight = FontWeight.Normal, + textAlign = textAlignment.textAlignment, + color = color, + platformStyle = PlatformTextStyle(includeFontPadding = false), + ) +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt index fb6a3d9e..587bce24 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.rounded.Menu import androidx.compose.material3.ExperimentalMaterial3Api @@ -53,6 +54,7 @@ fun ReaderTopBar( showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit, showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, + navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit, navigateBack: (ReaderEvent.OnNavigateBack) -> Unit ) { val activity = LocalActivity.current @@ -138,6 +140,15 @@ fun ReaderTopBar( } } + IconButton( + icon = Icons.Default.Bolt, + contentDescription = R.string.rsvp_open_from_reader_desc, + disableOnClick = false, + enabled = !lockMenu + ) { + navigateToRsvp(ReaderEvent.OnNavigateToRsvp(book.id)) + } + IconButton( icon = Icons.Default.Settings, contentDescription = R.string.open_reader_settings_content_desc, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt new file mode 100644 index 00000000..080b1951 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt @@ -0,0 +1,47 @@ +package org.dueattendant149.bookshelf.ui.reader + +import android.view.ActionMode +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.Window +import android.view.WindowManager +import android.view.SearchEvent +import android.view.accessibility.AccessibilityEvent + +class VolumeKeyWindowCallback( + private val delegate: Window.Callback?, + private val onKeyEvent: (KeyEvent) -> Boolean, +) : Window.Callback { + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (onKeyEvent(event)) return true + return delegate?.dispatchKeyEvent(event) ?: false + } + + override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean = delegate?.dispatchKeyShortcutEvent(event) ?: false + override fun dispatchTouchEvent(event: MotionEvent): Boolean = delegate?.dispatchTouchEvent(event) ?: false + override fun dispatchTrackballEvent(event: MotionEvent): Boolean = delegate?.dispatchTrackballEvent(event) ?: false + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean = delegate?.dispatchGenericMotionEvent(event) ?: false + override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = delegate?.dispatchPopulateAccessibilityEvent(event) ?: false + override fun onCreatePanelView(featureId: Int): View? = delegate?.onCreatePanelView(featureId) + override fun onCreatePanelMenu(featureId: Int, menu: Menu): Boolean = delegate?.onCreatePanelMenu(featureId, menu) ?: false + override fun onPreparePanel(featureId: Int, view: View?, menu: Menu): Boolean = delegate?.onPreparePanel(featureId, view, menu) ?: false + override fun onMenuOpened(featureId: Int, menu: Menu): Boolean = delegate?.onMenuOpened(featureId, menu) ?: false + override fun onMenuItemSelected(featureId: Int, item: MenuItem): Boolean = delegate?.onMenuItemSelected(featureId, item) ?: false + override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams) { delegate?.onWindowAttributesChanged(attrs) } + override fun onContentChanged() { delegate?.onContentChanged() } + override fun onWindowFocusChanged(hasFocus: Boolean) { delegate?.onWindowFocusChanged(hasFocus) } + override fun onWindowStartingActionMode(callback: ActionMode.Callback?): ActionMode? = delegate?.onWindowStartingActionMode(callback) + override fun onWindowStartingActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? = delegate?.onWindowStartingActionMode(callback, type) + override fun onActionModeStarted(mode: ActionMode) { delegate?.onActionModeStarted(mode) } + override fun onActionModeFinished(mode: ActionMode) { delegate?.onActionModeFinished(mode) } + override fun onPanelClosed(featureId: Int, menu: Menu) { delegate?.onPanelClosed(featureId, menu) } + override fun onSearchRequested(): Boolean = delegate?.onSearchRequested() ?: false + override fun onSearchRequested(event: SearchEvent?): Boolean = delegate?.onSearchRequested(event) ?: false + override fun onAttachedToWindow() { delegate?.onAttachedToWindow() } + override fun onDetachedFromWindow() { delegate?.onDetachedFromWindow() } + override fun onPointerCaptureChanged(hasCapture: Boolean) { delegate?.onPointerCaptureChanged(hasCapture) } +} \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt new file mode 100644 index 00000000..c945006f --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt @@ -0,0 +1,420 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.rsvp + +import android.view.WindowManager +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpToken +import org.dueattendant149.bookshelf.presentation.rsvp.RsvpEvent +import org.dueattendant149.bookshelf.presentation.rsvp.RsvpModel +import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity +import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings +import kotlin.math.roundToInt + +private val LocalRsvpContainerWidth = staticCompositionLocalOf { 360.dp } + +private const val RSVP_MIN_FONT_SP = 16 + +@Composable +private fun computeRsvpFontSize( + word: String, + maxFontSize: TextUnit, + containerWidth: Dp, + paddingDp: Dp, +): TextUnit { + val density = androidx.compose.ui.platform.LocalDensity.current + val textMeasurer = rememberTextMeasurer() + val availablePx = with(density) { (containerWidth - paddingDp * 2).toPx() }.coerceAtLeast(1f) + + // Binary search the largest font size that still fits. + var low = RSVP_MIN_FONT_SP.toFloat() + var high = maxFontSize.value + var best = low + val textStyle = TextStyle( + fontSize = 1.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Normal, + ) + repeat(10) { + val mid = (low + high) / 2f + val width = textMeasurer.measure( + text = word, + style = textStyle.copy(fontSize = mid.sp), + constraints = Constraints(), + ).size.width.toFloat() + if (width <= availablePx) { + best = mid + low = mid + } else { + high = mid + } + } + return if (best.sp.value < maxFontSize.value) best.sp else maxFontSize +} + +@Composable +fun RsvpContent(model: RsvpModel) { + val state by model.state.collectAsStateWithLifecycle() + val engineState by model.engineState.collectAsStateWithLifecycle() + val settings = LocalSettings.current + val activity = LocalActivity.current + + DisposableEffect(Unit) { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + onDispose { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + BackHandler(enabled = true) { + model.onEvent(RsvpEvent.OnClose) + } + + val token = engineState.currentToken + val tokenCount = engineState.tokens.size.coerceAtLeast(1) + val progress = engineState.progress + val wpm = engineState.wpm.coerceAtLeast(1) + val remainingMinutes = remember(engineState.currentIndex, tokenCount, wpm) { + (((tokenCount - engineState.currentIndex).coerceAtLeast(1)).toFloat() / wpm.toFloat()) + .roundToInt() + .coerceAtLeast(1) + } + + val screenWidth = LocalConfiguration.current.screenWidthDp.dp + + Scaffold( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { padding -> + CompositionLocalProvider(LocalRsvpContainerWidth provides screenWidth) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + model.onEvent( + if (engineState.isPlaying) RsvpEvent.OnPlayPause + else RsvpEvent.OnToggleControls + ) + }, + onDoubleTap = { model.onEvent(RsvpEvent.OnPlayPause) } + ) + } + ) { + when { + state.isLoading -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.rsvp_loading), + style = MaterialTheme.typography.bodyLarge + ) + } + } + + state.errorMessage != null -> { + Text( + text = state.errorMessage ?: stringResource(R.string.rsvp_no_text), + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.error + ) + } + + token != null -> { + WordStage( + token = token, + fontSizeSp = settings.rsvpFontSize.value.coerceIn(20, 96), + showFocusGuide = settings.rsvpShowFocusGuide.value, + progress = progress, + currentIndex = engineState.currentIndex, + tokenCount = tokenCount, + remainingMinutes = remainingMinutes, + modifier = Modifier.align(Alignment.Center) + ) + + AnimatedVisibility( + visible = state.showControls || !engineState.isPlaying, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + ControlsBar( + isPlaying = engineState.isPlaying, + wpm = wpm, + onPlayPause = { model.onEvent(RsvpEvent.OnPlayPause) }, + onSkipBack = { model.onEvent(RsvpEvent.OnSkip(-10)) }, + onSkipForward = { model.onEvent(RsvpEvent.OnSkip(10)) }, + onWpmChange = { model.onEvent(RsvpEvent.OnWpmChange(it)) }, + onClose = { model.onEvent(RsvpEvent.OnClose) }, + onSeek = { model.onEvent(RsvpEvent.OnSeekToProgress(it)) }, + progress = progress + ) + } + } + } + } + } + } +} + +@Composable +private fun WordStage( + token: RsvpToken, + fontSizeSp: Int, + showFocusGuide: Boolean, + progress: Float, + currentIndex: Int, + tokenCount: Int, + remainingMinutes: Int, + modifier: Modifier = Modifier, +) { + val pivotColor = MaterialTheme.colorScheme.primary + val bodyColor = MaterialTheme.colorScheme.onSurface + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Top progress bar + Slider( + value = progress, + onValueChange = {}, + enabled = false, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(40.dp)) + + // Focus guide top + if (showFocusGuide) { + Text( + text = stringResource(R.string.rsvp_focus_guide_top), + color = pivotColor.copy(alpha = 0.5f), + fontSize = 18.sp + ) + Spacer(Modifier.height(4.dp)) + } + + // Word with pivot highlight, auto-shrunk and centered + WordText( + token = token, + fontSizeSp = fontSizeSp, + containerWidth = LocalRsvpContainerWidth.current, + bodyColor = bodyColor, + pivotColor = pivotColor + ) + + + // Focus guide bottom + if (showFocusGuide) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.rsvp_focus_guide_bottom), + color = pivotColor.copy(alpha = 0.5f), + fontSize = 18.sp + ) + } + + Spacer(Modifier.height(40.dp)) + + // Stats + Text( + text = stringResource(R.string.rsvp_progress_label, currentIndex + 1, tokenCount), + color = bodyColor.copy(alpha = 0.6f), + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = stringResource(R.string.rsvp_remaining_label, remainingMinutes.coerceAtLeast(0)), + color = bodyColor.copy(alpha = 0.4f), + style = MaterialTheme.typography.bodySmall + ) + } +} + +@Composable +private fun ControlsBar( + isPlaying: Boolean, + wpm: Int, + onPlayPause: () -> Unit, + onSkipBack: () -> Unit, + onSkipForward: () -> Unit, + onWpmChange: (Int) -> Unit, + onClose: () -> Unit, + onSeek: (Float) -> Unit, + progress: Float, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + // Seek slider (allows tapping to seek) + Slider( + value = progress, + onValueChange = onSeek, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.rsvp_wpm_label, wpm), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + Slider( + value = wpm.toFloat(), + onValueChange = { onWpmChange(it.roundToInt()) }, + valueRange = 100f..900f, + steps = 15, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.rsvp_back) + ) + } + IconButton(onClick = onSkipBack) { + Icon( + imageVector = Icons.Default.FastRewind, + contentDescription = stringResource(R.string.rsvp_skip_back_10) + ) + } + IconButton(onClick = onPlayPause, modifier = Modifier.width(72.dp)) { + Icon( + imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = stringResource( + if (isPlaying) R.string.rsvp_pause else R.string.rsvp_play + ), + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) + } + IconButton(onClick = onSkipForward) { + Icon( + imageVector = Icons.Default.FastForward, + contentDescription = stringResource(R.string.rsvp_skip_forward_10) + ) + } + Spacer(Modifier.width(48.dp)) + } + } +} + +@Composable +private fun WordText( + token: RsvpToken, + fontSizeSp: Int, + containerWidth: Dp, + bodyColor: Color, + pivotColor: Color, +) { + val autoFontSize = computeRsvpFontSize( + word = token.word, + maxFontSize = fontSizeSp.sp, + containerWidth = containerWidth, + paddingDp = 32.dp, + ) + val style = SpanStyle( + fontSize = autoFontSize, + fontFamily = FontFamily.Monospace, + ) + val annotatedWord = buildAnnotatedString { + withStyle(style.copy(color = bodyColor.copy(alpha = 0.55f), fontWeight = FontWeight.Normal)) { + append(token.prefix) + } + withStyle(style.copy(color = pivotColor, fontWeight = FontWeight.Bold)) { + append(token.pivot) + } + withStyle(style.copy(color = bodyColor.copy(alpha = 0.55f), fontWeight = FontWeight.Normal)) { + append(token.suffix) + } + } + + Text( + text = annotatedWord, + maxLines = 1, + softWrap = false, + modifier = Modifier + .fillMaxWidth() + .height(((fontSizeSp * 1.6f).toInt()).dp), + ) +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt new file mode 100644 index 00000000..4fd399ab --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt @@ -0,0 +1,328 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.search + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse +import org.dueattendant149.bookshelf.presentation.search.SearchModel +import org.dueattendant149.bookshelf.ui.common.components.book.BookCard +import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton +import org.dueattendant149.bookshelf.ui.common.components.common.SearchTextField +import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder +import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchContent(model: SearchModel) { + val state by model.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + var selectedUploadType by remember { mutableStateOf("ebook") } + var uploadMenuExpanded by remember { mutableStateOf(false) } + + val filePicker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + uri ?: return@rememberLauncherForActivityResult + val file = uriToFile(context, uri) ?: return@rememberLauncherForActivityResult + val uploadType = inferUploadType(file.name) ?: selectedUploadType + model.uploadBook(file, uploadType) + } + + LaunchedEffect(state.downloadSuccess) { + state.downloadSuccess?.let { title -> + snackbarHostState.showSnackbar("Download started: $title") + model.clearDownloadSuccess() + } + } + + LaunchedEffect(state.uploadSuccess) { + state.uploadSuccess?.let { message -> + snackbarHostState.showSnackbar(message) + model.clearUploadSuccess() + } + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.search_screen)) } + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + SearchTextField( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + initialQuery = state.query, + onQueryChange = model::setQuery, + onSearch = model::search + ) + + Button( + onClick = { filePicker.launch("*/*") }, + enabled = !state.isUploading, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + if (state.isUploading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(Modifier.width(8.dp)) + } + Text("Загрузить в библиотеку") + } + + ExposedDropdownMenuBox( + expanded = uploadMenuExpanded, + onExpandedChange = { uploadMenuExpanded = it }, + modifier = Modifier.fillMaxWidth() + ) { + OutlinedTextField( + value = if (selectedUploadType == "audiobook") "Аудиокнига" else "Книга (EPUB/FB2/PDF)", + onValueChange = {}, + readOnly = true, + label = { Text("Тип загрузки") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = uploadMenuExpanded) }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + ) + ExposedDropdownMenu( + expanded = uploadMenuExpanded, + onDismissRequest = { uploadMenuExpanded = false } + ) { + DropdownMenuItem( + text = { Text("Книга (EPUB/FB2/PDF)") }, + onClick = { + selectedUploadType = "ebook" + uploadMenuExpanded = false + } + ) + DropdownMenuItem( + text = { Text("Аудиокнига") }, + onClick = { + selectedUploadType = "audiobook" + uploadMenuExpanded = false + } + ) + } + } + } + + if (state.isLoading) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } + + itemsIndexed( + items = state.results, + key = { index, item -> "${item.guid}_$index" } + ) { _, item -> + val dlProgress = state.downloadProgresses[item.title] + SearchResultCard( + item = item, + isDownloading = state.downloadingTitle == item.title, + downloadProgress = dlProgress, + onDownload = { model.startDownload(item) } + ) + } + } + + if (!state.isLoading && state.results.isEmpty() && state.query.isNotBlank() && state.error == null) { + EmptyPlaceholder( + modifier = Modifier.align(Alignment.Center), + message = stringResource(R.string.search_field_empty), + icon = rememberVectorPainter(Icons.Default.Search), + actionTitle = null + ) + } + + state.error?.let { error -> + ErrorPlaceholder( + modifier = Modifier.align(Alignment.Center), + errorMessage = error, + icon = rememberVectorPainter(Icons.Default.CloudOff), + actionTitle = stringResource(R.string.bookshelf_retry_action), + action = model::search + ) + } + } + } +} + +private fun inferUploadType(fileName: String?): String? { + if (fileName == null) return null + val name = fileName.lowercase() + return when { + AUDIOBOOK_EXTENSIONS.any { name.endsWith(it) } -> "audiobook" + EBOOK_EXTENSIONS.any { name.endsWith(it) } -> "ebook" + else -> null + } +} + +private val AUDIOBOOK_EXTENSIONS = listOf(".mp3", ".m4b", ".m4a", ".flac", ".ogg", ".aac", ".mp4") +private val EBOOK_EXTENSIONS = listOf(".epub", ".fb2", ".pdf", ".mobi") + +private fun uriToFile(context: Context, uri: Uri): File? { + return runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + val extension = inferExtensionFromMime(mime) + ?: inferExtensionFromUriPath(uri.lastPathSegment) + ?: "" + File(context.cacheDir, "upload$extension").apply { + outputStream().use { input.copyTo(it) } + } + } + }.getOrNull() +} + +private fun inferExtensionFromMime(mime: String): String? = when { + mime.contains("epub") -> ".epub" + mime.contains("pdf") -> ".pdf" + mime.contains("fb2") -> ".fb2" + mime.contains("mobi") -> ".mobi" + mime.contains("audio") -> if (mime.contains("mp4") || mime.contains("m4b")) ".m4b" else ".mp3" + else -> null +} + +private fun inferExtensionFromUriPath(path: String?): String? { + val name = path ?: "upload" + return when { + name.endsWith(".epub", ignoreCase = true) -> ".epub" + name.endsWith(".fb2", ignoreCase = true) -> ".fb2" + name.endsWith(".pdf", ignoreCase = true) -> ".pdf" + name.endsWith(".mobi", ignoreCase = true) -> ".mobi" + name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> ".m4b" + name.endsWith(".mp3", ignoreCase = true) -> ".mp3" + else -> null + } +} + +@Composable +private fun SearchResultCard( + item: SearchResultItemResponse, + isDownloading: Boolean, + downloadProgress: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse?, + onDownload: () -> Unit, +) { + val progress = downloadProgress?.progress?.toFloat()?.coerceIn(0f, 1f) ?: 0f + val description = buildString { + append(item.source) + append(" • ") + append(item.format) + if (item.sizeHuman.isNotBlank()) { + append(" • ") + append(item.sizeHuman) + } + } + + val book = org.dueattendant149.bookshelf.domain.model.library.Book( + title = item.title, + author = org.dueattendant149.bookshelf.core.ui.UIText.StringValue(item.author), + description = description, + filePath = "", + coverImage = null, + scrollIndex = 0, + scrollOffset = 0, + progress = progress, + lastOpened = null, + categories = emptyList(), + ) + + BookCard( + book = book, + progress = progress, + primaryButton = { + PrimaryActionButton( + label = if (isDownloading) "…" else stringResource(R.string.bookshelf_download_action), + enabled = !isDownloading, + onClick = onDownload + ) + } + ) +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt index f8a2e12f..d27b6e7c 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable fun SettingsContent( listState: LazyListState, scrollBehavior: TopAppBarScrollBehavior, + isRoot: Boolean = false, navigateToGeneralSettings: () -> Unit, navigateToAppearanceSettings: () -> Unit, navigateToReaderSettings: () -> Unit, @@ -28,6 +29,7 @@ fun SettingsContent( SettingsScaffold( listState = listState, scrollBehavior = scrollBehavior, + isRoot = isRoot, navigateToGeneralSettings = navigateToGeneralSettings, navigateToAppearanceSettings = navigateToAppearanceSettings, navigateToReaderSettings = navigateToReaderSettings, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt index 015a9164..108ff2e4 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll fun SettingsScaffold( listState: LazyListState, scrollBehavior: TopAppBarScrollBehavior, + isRoot: Boolean = false, navigateToGeneralSettings: () -> Unit, navigateToAppearanceSettings: () -> Unit, navigateToReaderSettings: () -> Unit, @@ -42,6 +43,7 @@ fun SettingsScaffold( topBar = { SettingsTopBar( scrollBehavior = scrollBehavior, + isRoot = isRoot, navigateToStart = navigateToStart, navigateBack = navigateBack ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt index 517833be..8e03826b 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt @@ -24,6 +24,7 @@ import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton @Composable fun SettingsTopBar( scrollBehavior: TopAppBarScrollBehavior, + isRoot: Boolean = false, navigateToStart: () -> Unit, navigateBack: () -> Unit ) { @@ -32,9 +33,11 @@ fun SettingsTopBar( StyledText(stringResource(id = R.string.settings_screen)) }, navigationIcon = { - NavigatorBackIconButton( - navigateBack = navigateBack - ) + if (!isRoot) { + NavigatorBackIconButton( + navigateBack = navigateBack + ) + } }, actions = { IconButton( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt index 706f3f16..a65d5ad1 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt @@ -21,6 +21,7 @@ import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components. import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGesturePullAnimOption import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureScrollOption import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureSensitivityOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.ReaderPaginationOption fun LazyListScope.ReadingModeSubcategory( titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary }, @@ -34,6 +35,10 @@ fun LazyListScope.ReadingModeSubcategory( showTitle = showTitle, showDivider = showDivider ) { + item { + ReaderPaginationOption() + } + item { HorizontalGestureOption() } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt new file mode 100644 index 00000000..f409b042 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt @@ -0,0 +1,28 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.ui.common.components.settings.SwitchWithTitle +import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings + +@Composable +fun ReaderPaginationOption() { + val settings = LocalSettings.current + val selected = settings.readerPagination.value + + SwitchWithTitle( + title = stringResource(id = R.string.reader_pagination_option), + description = stringResource(id = R.string.reader_pagination_option_desc), + selected = selected, + onClick = { + settings.readerPagination.update(!selected) + } + ) +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt index 17e5ad84..0e445da1 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt @@ -26,6 +26,12 @@ import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderOption import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderPaddingOption import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderThicknessOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpFocusGuideOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpFontSizeOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseChapterOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseLongWordsOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseParagraphOption +import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpWpmOption fun LazyListScope.ReadingSpeedSubcategory( titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary }, @@ -82,5 +88,29 @@ fun LazyListScope.ReadingSpeedSubcategory( item { HorizontalLimiterDimmingOption() } + + item { + RsvpWpmOption() + } + + item { + RsvpFontSizeOption() + } + + item { + RsvpPauseParagraphOption() + } + + item { + RsvpPauseChapterOption() + } + + item { + RsvpPauseLongWordsOption() + } + + item { + RsvpFocusGuideOption() + } } } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt new file mode 100644 index 00000000..f0254313 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt @@ -0,0 +1,26 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.ui.common.components.settings.SliderWithTitle +import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings + +@Composable +fun RsvpFontSizeOption() { + val settings = LocalSettings.current + + SliderWithTitle( + value = settings.rsvpFontSize.value to "sp", + fromValue = 24, + toValue = 96, + title = stringResource(id = R.string.rsvp_font_size_option), + onValueChange = { settings.rsvpFontSize.update(it) } + ) +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt new file mode 100644 index 00000000..ad47fc49 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt @@ -0,0 +1,72 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.ui.common.components.settings.SwitchWithTitle +import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings + +@Composable +fun RsvpPauseParagraphOption() { + val settings = LocalSettings.current + RsvpSwitchOption( + selected = settings.rsvpPauseOnParagraph.value, + titleRes = R.string.rsvp_pause_paragraph_option, + descRes = R.string.rsvp_pause_paragraph_option_desc, + onToggle = { settings.rsvpPauseOnParagraph.update(!settings.rsvpPauseOnParagraph.lastValue) }, + ) +} + +@Composable +fun RsvpPauseChapterOption() { + val settings = LocalSettings.current + RsvpSwitchOption( + selected = settings.rsvpPauseOnChapter.value, + titleRes = R.string.rsvp_pause_chapter_option, + descRes = R.string.rsvp_pause_chapter_option_desc, + onToggle = { settings.rsvpPauseOnChapter.update(!settings.rsvpPauseOnChapter.lastValue) }, + ) +} + +@Composable +fun RsvpPauseLongWordsOption() { + val settings = LocalSettings.current + RsvpSwitchOption( + selected = settings.rsvpPauseOnLongWords.value, + titleRes = R.string.rsvp_pause_long_words_option, + descRes = R.string.rsvp_pause_long_words_option_desc, + onToggle = { settings.rsvpPauseOnLongWords.update(!settings.rsvpPauseOnLongWords.lastValue) }, + ) +} + +@Composable +fun RsvpFocusGuideOption() { + val settings = LocalSettings.current + RsvpSwitchOption( + selected = settings.rsvpShowFocusGuide.value, + titleRes = R.string.rsvp_focus_guide_option, + descRes = R.string.rsvp_focus_guide_option_desc, + onToggle = { settings.rsvpShowFocusGuide.update(!settings.rsvpShowFocusGuide.lastValue) }, + ) +} + +@Composable +private fun RsvpSwitchOption( + selected: Boolean, + titleRes: Int, + descRes: Int, + onToggle: () -> Unit, +) { + SwitchWithTitle( + selected = selected, + title = stringResource(titleRes), + description = stringResource(descRes), + onClick = onToggle, + ) +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt new file mode 100644 index 00000000..393df5b1 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt @@ -0,0 +1,26 @@ +/* + * Book's Story — free and open-source Material You eBook reader. + * Copyright (C) 2024-2026 Acclorite + * SPDX-License-Identifier: GPL-3.0-only + */ + +package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import org.dueattendant149.bookshelf.R +import org.dueattendant149.bookshelf.ui.common.components.settings.SliderWithTitle +import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings + +@Composable +fun RsvpWpmOption() { + val settings = LocalSettings.current + + SliderWithTitle( + value = settings.rsvpWpm.value to stringResource(R.string.rsvp_wpm_label, settings.rsvpWpm.value), + fromValue = 100, + toValue = 900, + title = stringResource(id = R.string.rsvp_wpm_option), + onValueChange = { settings.rsvpWpm.update(it) } + ) +} diff --git a/app/src/main/res/drawable/audiobook_tab_filled.xml b/app/src/main/res/drawable/audiobook_tab_filled.xml new file mode 100644 index 00000000..7d76067b --- /dev/null +++ b/app/src/main/res/drawable/audiobook_tab_filled.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/audiobook_tab_outlined.xml b/app/src/main/res/drawable/audiobook_tab_outlined.xml new file mode 100644 index 00000000..70ef3334 --- /dev/null +++ b/app/src/main/res/drawable/audiobook_tab_outlined.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/book_tab_filled.xml b/app/src/main/res/drawable/book_tab_filled.xml new file mode 100644 index 00000000..eb22138a --- /dev/null +++ b/app/src/main/res/drawable/book_tab_filled.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/book_tab_outlined.xml b/app/src/main/res/drawable/book_tab_outlined.xml new file mode 100644 index 00000000..34bbd309 --- /dev/null +++ b/app/src/main/res/drawable/book_tab_outlined.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/search_tab_filled.xml b/app/src/main/res/drawable/search_tab_filled.xml new file mode 100644 index 00000000..4c39a21b --- /dev/null +++ b/app/src/main/res/drawable/search_tab_filled.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/search_tab_outlined.xml b/app/src/main/res/drawable/search_tab_outlined.xml new file mode 100644 index 00000000..4c39a21b --- /dev/null +++ b/app/src/main/res/drawable/search_tab_outlined.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/settings_tab_filled.xml b/app/src/main/res/drawable/settings_tab_filled.xml new file mode 100644 index 00000000..b57b97aa --- /dev/null +++ b/app/src/main/res/drawable/settings_tab_filled.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/settings_tab_outlined.xml b/app/src/main/res/drawable/settings_tab_outlined.xml new file mode 100644 index 00000000..69f70a88 --- /dev/null +++ b/app/src/main/res/drawable/settings_tab_outlined.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 02db3e07..2307e75e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -191,6 +191,8 @@ Прочее Скорость чтения Режим чтения + Постраничный режим + Перелистывание страниц вместо вертикальной прокрутки Система Главы Изображения @@ -535,6 +537,13 @@ Ваша библиотека История чтения Добавить книги + Книги + Удалённые книги + Аудиокниги + Удалённые аудиокниги + Поиск + Поиск в сети + Книжная полка Удалённая книжная полка Только оффлайн @@ -544,7 +553,8 @@ Ошибка Скачать Удалить - Играть + Слушать + Главы Не удалось синхронизировать прогресс чтения Не удалось синхронизировать прогресс воспроизведения Стрелка @@ -580,4 +590,34 @@ Не удалось создать TTS-задачу Ошибка загрузки TTS + + Скорочтение + Режим скорочтения + Играть + Пауза + Назад + −10 слов + +10 слов + %1$d слов/мин + %1$d / %2$d слов + ~%1$d мин осталось + Скорочтение + Запустить режим RSVP + + + Подготовка слов… + Не удалось извлечь текст для скорочтения + В книге нет слов + Скорочтение + Скорость (слов/мин) + Размер шрифта + Пауза между абзацами + Доп. пауза в конце каждого абзаца + Пауза между главами + Длинная пауза после заголовка главы + Замедление длинных слов + +30% времени на слова длиннее 9 букв + Показывать направляющие фокуса + ▲▼ метки над и под точкой фиксации + \ No newline at end of file diff --git a/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt b/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt index b6545ac5..b70e4a65 100644 --- a/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt +++ b/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt @@ -11,7 +11,10 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest @@ -102,6 +105,47 @@ class BookshelfApiServiceTest { assertEquals(42, libraries[0].itemCount) } + @Test + fun `getLibraryItems deserializes unified list with authors type and progress`() = runBlocking { + server.enqueue(jsonResponse(""" + { + "library": {"id":"lib-1","name":"Books","media_type":"book","item_count":2}, + "items": [ + { + "id":"e-1", + "title":"Ebook", + "authors":["A. Ebooker"], + "type":"ebook", + "media_type":"book", + "library_id":"lib-1", + "cover_url":"https://example.com/e.jpg", + "progress": {"reading":{"itemId":"e-1","libraryId":"lib-1","scrollIndex":1,"scrollOffset":2,"progress":0.5,"updatedAt":1000}} + }, + { + "id":"a-1", + "title":"Audiobook", + "authors":["B. Audio"], + "type":"audiobook", + "media_type":"book", + "library_id":"lib-1", + "cover_url":"https://example.com/a.jpg", + "duration":123.0, + "progress": {"playback":{"itemId":"a-1","libraryId":"lib-1","current_file":"t.m4b","current_position":10,"duration":100,"updatedAt":2000}} + } + ] + } + """.trimIndent())) + val response: Response = service.getLibraryItems("lib-1") + assertTrue(response.isSuccessful) + val items: List = response.body()!!.items + assertEquals(2, items.size) + assertEquals("ebook", items[0].type) + assertEquals("audiobook", items[1].type) + assertEquals(listOf("A. Ebooker"), items[0].authors) + assertEquals(0.5f, items[0].progress?.reading?.progress) + assertEquals(10L, items[1].progress?.playback?.currentPosition) + } + @Test fun `searchLibrary deserializes correctly`() = runBlocking { server.enqueue(jsonResponse("""