diff --git a/AGENTS.md b/AGENTS.md index 37a323a8..1361dcff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - **Аудио:** ExoPlayer (Media3) + MediaSession - **Фоновые задачи:** WorkManager - **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark) -- **minSdk:** 31 +- **minSdk:** 26 (план — поднять до 31 позже) - **compileSdk/targetSdk:** 36 ## Goal @@ -66,46 +66,14 @@ - Phase 1: fork + package rename completed. - Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed. -- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17→18) completed. +- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed. - Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed. - Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed. -- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) completed. -- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) completed. -- Phase 6: feature integration on `feature/ui-polish` completed: - - RemoteLibrary cache UI (offline-only filter, download/delete, progress indicator). - - Remote ebook opening in the existing reader. - - 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 `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. -- 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. +- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged. +- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged. +- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model. +- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`. +- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync. ## WARNs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 87ea6277..5d543541 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,7 +2,6 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") - id("org.jetbrains.kotlin.plugin.serialization") id("com.google.devtools.ksp") id("com.google.dagger.hilt.android") id("kotlin-parcelize") @@ -17,10 +16,10 @@ android { // Default configuration defaultConfig { applicationId = "org.dueattendant149.bookshelf" - minSdk = 31 + minSdk = 26 targetSdk = 36 - versionCode = 27 - versionName = "1.9.11" + versionCode = 14 + versionName = "1.8.0" vectorDrawables { useSupportLibrary = true @@ -180,14 +179,4 @@ dependencies { // Background work (cache, TTS sync) implementation("androidx.work:work-runtime-ktx:2.10.0") - implementation("androidx.hilt:hilt-work:1.2.0") - - // Test - testImplementation("junit:junit:4.13.2") - testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") - testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - testImplementation("com.squareup.retrofit2:retrofit:2.11.0") - testImplementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0") - testImplementation("com.squareup.okhttp3:okhttp:4.12.0") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } \ No newline at end of file diff --git a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json deleted file mode 100644 index 42583404..00000000 --- a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json +++ /dev/null @@ -1,500 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 18, - "identityHash": "989c23bd1fdbd69a7d65b94ae6cf381b", - "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 '', `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": "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, '989c23bd1fdbd69a7d65b94ae6cf381b')" - ] - } -} \ No newline at end of file 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 deleted file mode 100644 index fe24b49c..00000000 --- a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json +++ /dev/null @@ -1,507 +0,0 @@ -{ - "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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b47da2d..00225368 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,12 +22,6 @@ tools:node="remove" tools:ignore="ScopedStorage" /> - - - - - - - - - - - - - // Additional Migrations DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt index d3415c1f..f29b779f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt @@ -28,7 +28,6 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParser import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl import org.dueattendant149.bookshelf.data.parser.text.TextParser import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl -import org.dueattendant149.bookshelf.data.repository.AudiobookshelfRepositoryImpl import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl @@ -36,10 +35,7 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl -import org.dueattendant149.bookshelf.data.repository.ProgressRepositoryImpl import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl -import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl -import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository import org.dueattendant149.bookshelf.domain.repository.BookRepository import org.dueattendant149.bookshelf.domain.repository.CacheRepository import org.dueattendant149.bookshelf.domain.repository.CategoryRepository @@ -47,9 +43,7 @@ import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository import org.dueattendant149.bookshelf.domain.repository.HistoryRepository import org.dueattendant149.bookshelf.domain.repository.PermissionRepository -import org.dueattendant149.bookshelf.domain.repository.ProgressRepository import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository import javax.inject.Singleton @Module @@ -109,24 +103,6 @@ abstract class RepositoryModule { cacheRepositoryImpl: CacheRepositoryImpl ): CacheRepository - @Binds - @Singleton - abstract fun bindAudiobookshelfRepository( - audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl - ): AudiobookshelfRepository - - @Binds - @Singleton - abstract fun bindRemoteTtsRepository( - remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl - ): RemoteTtsRepository - - @Binds - @Singleton - abstract fun bindProgressRepository( - progressRepositoryImpl: ProgressRepositoryImpl - ): ProgressRepository - @Binds @Singleton abstract fun bindBookMapper( 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 da80b059..be6056a9 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,8 +32,6 @@ 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") @@ -41,10 +39,6 @@ data class BookEntity( @ColumnInfo(defaultValue = "0") val audioDuration: Long = 0L, @ColumnInfo(defaultValue = "") - val audioCurrentFile: String = "", - @ColumnInfo(defaultValue = "0") - val audioCurrentPosition: Long = 0L, - @ColumnInfo(defaultValue = "") val coverUrl: String = "", @ColumnInfo(defaultValue = "0") val lastSyncedAt: Long = 0L, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt index 4855ebb8..55e71b78 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt @@ -38,9 +38,6 @@ interface BookDao { @Query("SELECT * FROM bookentity WHERE libraryId=:libraryId") suspend fun findBooksByLibraryId(libraryId: String): List - @Query("SELECT * FROM bookentity WHERE remoteId != ''") - suspend fun findBooksWithRemoteId(): List - @Delete suspend fun deleteBook(book: BookEntity): Int 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 1939a28b..1495842b 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 = 19, + version = 17, autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), @@ -203,25 +203,6 @@ 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( - "ALTER TABLE BookEntity ADD COLUMN audioCurrentFile TEXT NOT NULL DEFAULT ''" - ) - database.execSQL( - "ALTER TABLE BookEntity ADD COLUMN audioCurrentPosition INTEGER NOT NULL DEFAULT 0" - ) - } - } - val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) { override fun migrate(database: SupportSQLiteDatabase) { // Add remote / bookshelf columns to BookEntity 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 771c4ba7..395bc2ba 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,12 +29,9 @@ 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, - audioCurrentFile = book.audioCurrentFile, - audioCurrentPosition = book.audioCurrentPosition, coverUrl = book.coverUrl, lastSyncedAt = book.lastSyncedAt, ) @@ -59,12 +56,9 @@ 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, - audioCurrentFile = bookEntity.audioCurrentFile, - audioCurrentPosition = bookEntity.audioCurrentPosition, coverUrl = bookEntity.coverUrl, lastSyncedAt = bookEntity.lastSyncedAt, ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt deleted file mode 100644 index 9a8eb029..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt +++ /dev/null @@ -1,118 +0,0 @@ -/* - * 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.playback - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.content.Intent -import android.os.Build -import android.os.Bundle -import androidx.annotation.OptIn -import androidx.core.app.NotificationCompat -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaSession -import androidx.media3.session.MediaSessionService -import dagger.hilt.android.AndroidEntryPoint -import org.dueattendant149.bookshelf.R -import org.dueattendant149.bookshelf.domain.model.library.Book -import org.dueattendant149.bookshelf.presentation.main.MainActivity -import javax.inject.Inject - -@AndroidEntryPoint -@OptIn(UnstableApi::class) -class AudioPlaybackService : MediaSessionService() { - - @Inject - lateinit var exoPlayer: ExoPlayer - - private var mediaSession: MediaSession? = null - - override fun onCreate() { - super.onCreate() - createNotificationChannel() - val intent = Intent(this, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - } else { - PendingIntent.FLAG_UPDATE_CURRENT - } - val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags) - mediaSession = MediaSession.Builder(this, exoPlayer) - .setSessionActivity(sessionActivity) - .build() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java) - } else { - @Suppress("DEPRECATION") - intent?.getParcelableExtra(EXTRA_BOOK) - } - - val notification = buildNotification(book) - startForeground(NOTIFICATION_ID, notification) - - return super.onStartCommand(intent, flags, startId) - } - - override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { - return mediaSession - } - - override fun onTaskRemoved(rootIntent: Intent?) { - val player = mediaSession?.player ?: exoPlayer - if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) { - stopSelf() - } - } - - override fun onDestroy() { - mediaSession?.run { - player.release() - release() - } - mediaSession = null - super.onDestroy() - } - - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - CHANNEL_ID, - getString(R.string.audio_playback_channel), - NotificationManager.IMPORTANCE_LOW - ).apply { - description = getString(R.string.audio_playback_channel_description) - } - val notificationManager = getSystemService(NotificationManager::class.java) - notificationManager.createNotificationChannel(channel) - } - } - - private fun buildNotification(book: Book?): Notification { - return NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(book?.title ?: getString(R.string.app_name)) - .setContentText(book?.author?.getAsString() ?: "") - .setSmallIcon(R.drawable.ic_notification) - .setOngoing(true) - .setSilent(true) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .build() - } - - companion object { - const val EXTRA_BOOK = "extra_book" - private const val CHANNEL_ID = "audio_playback" - private const val NOTIFICATION_ID = 1 - } -} 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 deleted file mode 100644 index 3eb01d73..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index a8c8dfa1..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 7e0550cf..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.dueattendant149.bookshelf.data.playback.di - -import android.content.Context -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.datasource.okhttp.OkHttpDataSource -import dagger.Module -import dagger.Provides -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 -@InstallIn(SingletonComponent::class) -object PlaybackModule { - - @Provides - @Singleton - fun providePlaybackAuthProvider(): PlaybackAuthProvider = PlaybackAuthProvider() - - @OptIn(UnstableApi::class) - @Provides - @Singleton - fun provideExoPlayer( - @ApplicationContext context: Context, - authProvider: PlaybackAuthProvider, - okHttpClient: OkHttpClient, - ): ExoPlayer { - val client = okHttpClient.newBuilder() - .addInterceptor(AuthorizationInterceptor(authProvider::token)) - .build() - - val dataSourceFactory = OkHttpDataSource.Factory(client) - val audioAttributes = AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) - .build() - - return ExoPlayer.Builder(context) - .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory)) - .setAudioAttributes(audioAttributes, true) - .setHandleAudioBecomingNoisy(true) - .build() - } -} 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 deleted file mode 100644 index f646e21f..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt index 061dfd13..1dbe391f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt @@ -1,12 +1,8 @@ package org.dueattendant149.bookshelf.data.remote.audiobookshelf import okhttp3.ResponseBody -import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest -import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse import retrofit2.Response -import retrofit2.http.Body import retrofit2.http.GET -import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Streaming @@ -38,15 +34,4 @@ interface AudiobookshelfApiService { suspend fun getProgress( @Path("itemId") itemId: String, ): Response - - @GET("api/items/{itemId}") - suspend fun getItem( - @Path("itemId") itemId: String, - ): Response - - @POST("api/me/progress/{itemId}") - suspend fun updateProgress( - @Path("itemId") itemId: String, - @Body request: PlaybackProgressUpdateRequest, - ): Response } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt deleted file mode 100644 index 39764b3a..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.dueattendant149.bookshelf.data.remote.audiobookshelf - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Request body for updating media progress directly on an Audiobookshelf server. - * - * Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract. - */ -@Serializable -data class PlaybackProgressUpdateRequest( - @SerialName("currentTime") - val currentTime: Double, - @SerialName("duration") - val duration: Double, - @SerialName("progress") - val progress: Double, - @SerialName("episodeId") - val episodeId: String? = null, -) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt deleted file mode 100644 index 99e92b6d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.dueattendant149.bookshelf.data.remote.audiobookshelf.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class AbsItemResponse( - val id: String = "", - val media: AbsMedia? = null, -) - -@Serializable -data class AbsMedia( - val metadata: AbsMediaMetadata? = null, - @SerialName("audioFiles") - val audioFiles: List = emptyList(), -) - -@Serializable -data class AbsMediaMetadata( - val title: String? = null, -) - -@Serializable -data class AbsAudioFile( - val ino: String = "", - val metadata: AbsAudioFileMetadata? = null, - val duration: Double = 0.0, -) - -@Serializable -data class AbsAudioFileMetadata( - val filename: String? = null, - val ext: String? = null, -) 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 0f0e3119..2b10badb 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,17 +1,13 @@ 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 import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest 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.TtsCreateRequest @@ -19,20 +15,15 @@ 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.Multipart -import retrofit2.http.Part +import retrofit2.http.POST 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). @@ -42,54 +33,16 @@ interface BookshelfApiService { // Health @GET("health") - suspend fun health(): Response + suspend fun health(): Response // Libraries / books @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( @Path("itemId") itemId: String, - ): Response - - @Streaming - @POST("api/v1/books/{itemId}/progress") - suspend fun updateReadingProgress( - @Path("itemId") itemId: String, - @Body request: ProgressUpdateRequest, - ): Response - - @Streaming - @POST("api/v1/books/{itemId}/playback-progress") - suspend fun updatePlaybackProgress( - @Path("itemId") itemId: String, - @Body request: PlaybackProgressUpdateRequest, - ): Response - - @GET("api/v1/books/library/{libraryId}/items") - suspend fun getLibraryItems( - @Path("libraryId") libraryId: String, - ): Response + ): Response @GET("api/v1/books/library/{libraryId}/search") suspend fun searchLibrary( @@ -130,13 +83,6 @@ 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 deleted file mode 100644 index 6d288f1d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * 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 deleted file mode 100644 index 24f86541..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt +++ /dev/null @@ -1,13 +0,0 @@ -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/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt deleted file mode 100644 index 1735741e..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Request body for updating the audio playback progress of a book on the bookshelf-api. - */ -@Serializable -data class PlaybackProgressUpdateRequest( - val itemId: String, - val libraryId: String, - @SerialName("current_file") - val currentFile: String, - @SerialName("current_position") - val currentPosition: Long, - val duration: Long, - @SerialName("updated_at") - val updatedAt: Long, -) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt deleted file mode 100644 index ca71749b..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Request body for updating the local reading progress of a book on the bookshelf-api. - */ -@Serializable -data class ProgressUpdateRequest( - val itemId: String, - val libraryId: String, - val scrollIndex: Int, - val scrollOffset: Int, - val progress: Float, - @SerialName("last_chapter") - val lastChapter: String? = null, - @SerialName("updated_at") - val updatedAt: Long, -) 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 deleted file mode 100644 index 3772973a..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index 09b0b626..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index b7602133..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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.repository - -import android.util.Log -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 -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AudiobookshelfRepositoryImpl - @Inject - constructor( - private val clientFactory: BookshelfApiClientFactory, - private val serverSettings: ServerSettings, - ) : AudiobookshelfRepository { - override suspend fun getAudioTracks(itemId: String): Result> { - return runCatching { - val bookshelfUrl = serverSettings.getBookshelfUrl() - if (bookshelfUrl.isNullOrBlank()) { - return Result.failure(IllegalStateException("Bookshelf API URL is not configured")) - } - - val client = clientFactory.provideClient(bookshelfUrl) - ?: return Result.failure(IllegalStateException("Failed to create Bookshelf API client")) - - val response = client.getAudioTracks(itemId) - if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to fetch tracks: ${response.code()}")) - } - - response.body().orEmpty() - .mapIndexed { index, track -> - AudioTrack( - fileId = track.fileId, - title = track.title.takeIf { it.isNotBlank() } ?: "Track ${index + 1}", - durationMs = (track.duration * 1000).toLong(), - order = index, - ) - } - .sortedBy { it.order } - }.onFailure { - Log.e("AudiobookshelfRepo", "getAudioTracks failed", it) - } - } - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt index feee1b91..e3cc4dce 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt @@ -92,10 +92,4 @@ class BookRepositoryImpl @Inject constructor( } } } - - override suspend fun getBooksWithRemoteId(): Result> = runCatching { - withContext(Dispatchers.IO) { - database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) } - } - } } \ No newline at end of file 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 f2cf6528..f667f68d 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,26 +7,22 @@ package org.dueattendant149.bookshelf.data.repository import android.app.Application -import android.util.Log +import android.webkit.URLUtil 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.bookshelfapi.BookshelfApiClientFactory +import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory 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 @@ -38,275 +34,156 @@ class CacheRepositoryImpl private val application: Application, private val database: BookDatabase, private val bookMapper: BookMapper, - private val apiFactory: BookshelfApiClientFactory, + private val apiFactory: AudiobookshelfApiClientFactory, 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.remoteId.isNotBlank()) { "Book has no remote id." } + 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." } - val existing = database.bookDao.findBookByRemoteId(book.remoteId) - val localBookId = existing?.id ?: insertLocalBook(book) + val existing = database.bookDao.findBookByRemoteId(book.remoteId) + val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.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 - } - - 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, - ) + 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 } - ) - } - 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 - } + val url = serverSettings.getAbsUrl() + val token = serverSettings.getAbsToken() + if (url.isNullOrBlank() || token.isNullOrBlank()) { + throw IllegalStateException("Audiobookshelf server is not configured.") + } - 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) + val client = apiFactory.provideClient(url, token) + ?: throw IllegalStateException("Could not create Audiobookshelf client.") - audioFileDao.update( - (cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId)) - .copy(localPath = file.absolutePath) - ) + val response = client.downloadBook(book.remoteId) + if (!response.isSuccessful) { + throw IllegalStateException("Download failed: ${response.code()}") + } - 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, + 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, + ) ) - ) - } - } - 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) + cachedFileDao.insert( + CachedFileEntity( + bookId = inserted.id, + type = "ebook", + remoteUrl = "$url/api/items/${book.remoteId}/download", + localPath = file.absolutePath, + status = "completed", + progress = 1f, + ) ) - ) - } - } - 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) } + inserted.id + } } - 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) { + override suspend fun deleteCache(remoteId: String): Result = runCatching { + withContext(Dispatchers.IO) { + val book = database.bookDao.findBookByRemoteId(remoteId) + ?: throw IllegalStateException("Book not found") 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 + 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)) + } } - 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: 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" } } - - 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/ProgressRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt deleted file mode 100644 index 67b6466d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * 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.repository - -import android.util.Log -import kotlinx.coroutines.Dispatchers -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.remote.bookshelfapi.BookshelfApiClientFactory -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest -import org.dueattendant149.bookshelf.data.settings.ServerSettings -import org.dueattendant149.bookshelf.domain.model.library.Book -import org.dueattendant149.bookshelf.domain.repository.ProgressRepository -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ProgressRepositoryImpl - @Inject - constructor( - private val database: BookDatabase, - private val bookMapper: BookMapper, - private val clientFactory: BookshelfApiClientFactory, - private val serverSettings: ServerSettings, - ) : ProgressRepository { - override suspend fun syncReadingProgress( - book: Book, - lastChapter: String?, - ): Result = runCatching { - withContext(Dispatchers.IO) { - val client = client() ?: return@withContext - val request = - ProgressUpdateRequest( - itemId = book.remoteId, - libraryId = book.libraryId, - scrollIndex = book.scrollIndex, - scrollOffset = book.scrollOffset, - progress = book.progress, - lastChapter = lastChapter, - updatedAt = System.currentTimeMillis(), - ) - - val response = client.updateReadingProgress(book.remoteId, request) - if (!response.isSuccessful) { - throw RuntimeException("Failed to sync reading progress: ${response.code()}") - } - - updateLastSyncedAt(book) - } - }.onFailure { - Log.e(TAG, "syncReadingProgress failed for ${book.remoteId}", it) - } - - override suspend fun syncPlaybackProgress( - book: Book, - currentFile: String, - position: Long, - duration: Long, - ): Result = runCatching { - withContext(Dispatchers.IO) { - val client = client() ?: return@withContext - val request = - PlaybackProgressUpdateRequest( - itemId = book.remoteId, - libraryId = book.libraryId, - currentFile = currentFile, - currentPosition = position, - duration = duration, - updatedAt = System.currentTimeMillis(), - ) - - val response = client.updatePlaybackProgress(book.remoteId, request) - if (!response.isSuccessful) { - throw RuntimeException("Failed to sync playback progress: ${response.code()}") - } - - updateLastSyncedAt(book) - } - }.onFailure { - Log.e(TAG, "syncPlaybackProgress failed for ${book.remoteId}", it) - } - - override suspend fun getBooksWithRemoteId(): Result> = runCatching { - withContext(Dispatchers.IO) { - database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) } - } - }.onFailure { - Log.e(TAG, "getBooksWithRemoteId failed", it) - } - - private suspend fun client() = - serverSettings.getBookshelfUrl()?.let { url -> - clientFactory.provideClient(url) - } - - private suspend fun updateLastSyncedAt(book: Book) { - val entity = bookMapper.toBookEntity(book.copy(lastSyncedAt = System.currentTimeMillis())) - database.bookDao.updateBook(entity) - } - - companion object { - private const val TAG = "ProgressRepository" - } - } 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 62e4539b..25ac31e4 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,21 +7,9 @@ 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 @@ -35,8 +23,6 @@ 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 { @@ -52,42 +38,7 @@ class RemoteLibraryRepositoryImpl } override suspend fun getBooks(libraryId: String): Result> { - 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) - } + return searchLibrary(libraryId, "") } override suspend fun searchLibrary( @@ -106,59 +57,6 @@ 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() = @@ -169,54 +67,7 @@ class RemoteLibraryRepositoryImpl itemCount = itemCount, ) - 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() = + private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() = Book( id = 0, title = title, @@ -232,7 +83,6 @@ 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/repository/RemoteTtsRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt deleted file mode 100644 index 36a6a16a..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt +++ /dev/null @@ -1,152 +0,0 @@ -/* - * 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.repository - -import android.util.Log -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEngineResponse -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoiceResponse -import org.dueattendant149.bookshelf.data.settings.ServerSettings -import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine -import org.dueattendant149.bookshelf.domain.model.tts.TtsJob -import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class RemoteTtsRepositoryImpl - @Inject - constructor( - private val clientFactory: BookshelfApiClientFactory, - private val serverSettings: ServerSettings, - ) : RemoteTtsRepository { - override suspend fun fetchEngines(): Result> { - return runCatching { - val client = client() ?: return Result.success(emptyList()) - val response = client.getTtsEngines() - if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to fetch TTS engines: ${response.code()}")) - } - response.body()?.engines.orEmpty().map { it.toDomain() } - }.onFailure { - Log.e("RemoteTtsRepo", "fetchEngines failed", it) - } - } - - override suspend fun fetchVoices(engine: String?): Result> { - return runCatching { - val client = client() ?: return Result.success(emptyList()) - val response = client.getTtsVoices(engine) - if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to fetch TTS voices: ${response.code()}")) - } - response.body()?.voices.orEmpty().map { it.toDomain() } - }.onFailure { - Log.e("RemoteTtsRepo", "fetchVoices failed", it) - } - } - - override suspend fun createJob( - bookId: String, - engine: String, - voiceId: String, - speed: Double, - ): Result { - return runCatching { - val client = client() ?: return Result.failure(RuntimeException("Server not configured")) - val response = client.createTtsJob( - TtsCreateRequest( - bookId = bookId, - engine = engine, - voiceId = voiceId, - speed = speed, - ) - ) - if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to create TTS job: ${response.code()}")) - } - val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response")) - body.toDomain() - }.onFailure { - Log.e("RemoteTtsRepo", "createJob failed", it) - } - } - - override suspend fun getJob(jobId: String): Result { - return runCatching { - val client = client() ?: return Result.failure(RuntimeException("Server not configured")) - val response = client.getTtsJobStatus(jobId) - if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to fetch TTS job: ${response.code()}")) - } - val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response")) - body.toDomain() - }.onFailure { - Log.e("RemoteTtsRepo", "getJob failed", it) - } - } - - override suspend fun downloadAudio(jobId: String): Result { - return runCatching { - val client = client() ?: return Result.failure(RuntimeException("Server not configured")) - val response = client.downloadTtsAudio(jobId) - if (!response.isSuccessful) { - return Result.failure(RuntimeException("Failed to download TTS audio: ${response.code()}")) - } - response.body()?.bytes() ?: return Result.failure(RuntimeException("Empty TTS audio response")) - }.onFailure { - Log.e("RemoteTtsRepo", "downloadAudio failed", it) - } - } - - private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) } - - private fun TtsEngineResponse.toDomain() = - TtsEngine( - id = id, - name = name, - supportsStreaming = capabilities.supportsStreaming, - supportsCloning = capabilities.supportsCloning, - maxTextLength = capabilities.maxTextLength, - needsNetwork = capabilities.needsNetwork, - ) - - private fun TtsVoiceResponse.toDomain() = - TtsVoice( - id = id, - name = name, - language = language, - engine = engine, - gender = gender, - quality = quality, - requiresReference = requiresReference, - ) - - private fun TtsJobResponse.toDomain() = - TtsJob( - jobId = jobId, - bookId = bookId, - title = title, - author = author, - engine = engine, - voiceId = voiceId, - speed = speed, - status = status, - progress = progress, - currentChapter = currentChapter, - totalChapters = totalChapters, - completedChapters = completedChapters, - outputPath = outputPath, - error = error, - createdAt = createdAt, - startedAt = startedAt, - completedAt = completedAt, - ) - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt index 2ee0ca81..05ed56a8 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt @@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.data.settings import androidx.datastore.preferences.core.stringPreferencesKey import org.dueattendant149.bookshelf.data.local.data_store.DataStore -import org.dueattendant149.bookshelf.domain.util.ensureUriScheme import org.dueattendant149.bookshelf.domain.util.isValidUri import org.dueattendant149.bookshelf.domain.util.normalizeUri import javax.inject.Inject @@ -48,12 +47,11 @@ class ServerSettings * or null when all credentials are present. */ suspend fun getMissingCredentialsError(): String? { - val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)?.trim() + val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey) if (bookshelfUrl.isNullOrBlank()) { return "Bookshelf API URL is missing" } - val normalizedBookshelfUrl = bookshelfUrl.ensureUriScheme() - if (!normalizedBookshelfUrl.isValidUri()) { + if (!bookshelfUrl.isValidUri()) { return "Bookshelf API URL is invalid: $bookshelfUrl" } if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) { @@ -69,8 +67,8 @@ class ServerSettings val url = dataStore.getNullableData(bookshelfUrlKey)?.trim() return when { url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty")) - !url.ensureUriScheme().isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url")) - else -> Result.success(url.ensureUriScheme().normalizeUri()) + !url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url")) + else -> Result.success(url.normalizeUri()) } } @@ -82,8 +80,6 @@ class ServerSettings private fun String.normalizeIfValid(): String? { val trimmed = trim() - if (trimmed.isBlank()) return null - val withScheme = trimmed.ensureUriScheme() - return if (withScheme.isValidUri()) withScheme.normalizeUri() else null + return if (trimmed.isValidUri()) trimmed.normalizeUri() else null } } 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 4740de2c..2a6165da 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,26 +180,6 @@ 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 ) @@ -231,9 +211,6 @@ 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/data/worker/CacheDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt index 67b26258..03fd76e7 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt @@ -96,14 +96,13 @@ class CacheDownloadWorker( } if (hasAudio) { - downloadAudioTracks( + downloadFile( database = database, bookId = bookId, - absUrl = absUrl, - remoteId = remoteId, - bookDir = bookDir, - client = client, - ) + type = "audio", + remoteUrl = "$absUrl/api/items/$remoteId/download", + localFile = File(bookDir, "audio"), + ) { client.downloadBook(remoteId) } } Result.success() @@ -113,65 +112,6 @@ class CacheDownloadWorker( } } - private suspend fun downloadAudioTracks( - database: BookDatabase, - bookId: Int, - absUrl: String, - remoteId: String, - bookDir: File, - client: org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiService, - ) { - val audioDir = File(bookDir, "audio").apply { mkdirs() } - - val itemResponse = client.getItem(remoteId) - if (!itemResponse.isSuccessful) { - throw IOException("Failed to fetch item for audio tracks: ${itemResponse.code()}") - } - val item = itemResponse.body() ?: throw IOException("Empty item response") - val audioFiles = item.media?.audioFiles.orEmpty() - - if (audioFiles.isEmpty()) { - Log.w(TAG, "No audio files found for book $bookId") - return - } - - val totalTracks = audioFiles.size - audioFiles.forEachIndexed { index, file -> - val fileId = file.ino - val trackFile = File(audioDir, "${index}_${fileId}") - val record = getOrCreateRecord( - database, - bookId, - "audio_${index}", - "$absUrl/api/items/$remoteId/file/$fileId", - ) - - if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) { - return@forEachIndexed - } - - database.cachedFileDao.update(record.copy(status = "downloading", progress = index.toFloat() / totalTracks)) - - val response = client.downloadAudioFile(remoteId, fileId) - if (!response.isSuccessful) { - throw IOException("Audio track $index download failed: ${response.code()}") - } - val body = response.body() ?: throw IOException("Empty audio response body") - - body.byteStream().use { input -> - trackFile.outputStream().use { output -> input.copyTo(output) } - } - - database.cachedFileDao.update( - record.copy( - status = "completed", - progress = (index + 1).toFloat() / totalTracks, - localPath = trackFile.absolutePath, - ), - ) - } - } - private suspend fun downloadFile( database: BookDatabase, bookId: Int, diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt deleted file mode 100644 index 362729e1..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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.worker - -import android.content.Context -import android.util.Log -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import dagger.hilt.EntryPoint -import dagger.hilt.InstallIn -import dagger.hilt.android.EntryPointAccessors -import dagger.hilt.components.SingletonComponent -import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase -import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase -import java.util.concurrent.TimeUnit - -/** - * Periodic worker that syncs reading and playback progress for all books - * that have a remote (bookshelf-api) identifier. - */ -class ProgressSyncWorker( - context: Context, - params: WorkerParameters, -) : CoroutineWorker(context, params) { - companion object { - const val WORK_NAME = "progress-sync-worker" - - fun schedule(workManager: WorkManager) { - val request = - PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) - .setConstraints( - Constraints - .Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build(), - ) - .build() - - workManager.enqueueUniquePeriodicWork( - WORK_NAME, - ExistingPeriodicWorkPolicy.KEEP, - request, - ) - } - } - - private val entryPoint: ProgressSyncEntryPoint by lazy { - EntryPointAccessors.fromApplication(applicationContext, ProgressSyncEntryPoint::class.java) - } - - override suspend fun doWork(): Result { - val syncReading = entryPoint.syncReadingProgressUseCase() - val syncPlayback = entryPoint.syncPlaybackProgressUseCase() - - val books = entryPoint.progressRepository().getBooksWithRemoteId().getOrElse { - Log.e(TAG, "Failed to load books with remote IDs", it) - return Result.retry() - } - - var failures = 0 - books.forEach { book -> - syncReading(book, lastChapter = null).onFailure { - Log.w(TAG, "Reading progress sync failed for ${book.remoteId}", it) - failures++ - } - - if (book.hasAudio && book.audioDuration > 0L) { - syncPlayback( - book = book, - currentFile = book.audioCurrentFile, - position = book.audioCurrentPosition, - duration = book.audioDuration, - ).onFailure { - Log.w(TAG, "Playback progress sync failed for ${book.remoteId}", it) - failures++ - } - } - } - - return if (failures == 0) { - Result.success() - } else { - Log.w(TAG, "$failures/${books.size} books failed to sync, retrying") - Result.retry() - } - } - - @EntryPoint - @InstallIn(SingletonComponent::class) - interface ProgressSyncEntryPoint { - fun syncReadingProgressUseCase(): SyncReadingProgressUseCase - fun syncPlaybackProgressUseCase(): SyncPlaybackProgressUseCase - fun progressRepository(): org.dueattendant149.bookshelf.domain.repository.ProgressRepository - } -} - -private const val TAG = "ProgressSyncWorker" diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt deleted file mode 100644 index 6c88e57b..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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.worker - -import android.content.Context -import android.util.Log -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.Data -import androidx.work.WorkerParameters -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.delay -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository -import java.io.File - -@HiltWorker -class TtsDownloadWorker - @AssistedInject - constructor( - @Assisted context: Context, - @Assisted params: WorkerParameters, - private val remoteTtsRepository: RemoteTtsRepository, - ) : CoroutineWorker(context, params) { - - override suspend fun doWork(): Result { - val jobId = inputData.getString(KEY_JOB_ID) - ?: return Result.failure(errorData("Missing job ID")) - val bookId = inputData.getString(KEY_BOOK_ID) ?: "" - - val maxAttempts = inputData.getInt(KEY_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS) - val pollIntervalMs = inputData.getLong(KEY_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS) - - repeat(maxAttempts) { attempt -> - val result = remoteTtsRepository.getJob(jobId) - val job = result.getOrElse { - Log.e(TAG, "Failed to poll job $jobId (attempt $attempt)", it) - return Result.retry() - } - - setProgress( - progressData( - jobId = jobId, - status = job.status, - progress = job.progress, - currentChapter = job.currentChapter, - completedChapters = job.completedChapters, - totalChapters = job.totalChapters, - ) - ) - - when { - job.isCompleted -> { - val downloadResult = remoteTtsRepository.downloadAudio(jobId) - val audioBytes = downloadResult.getOrElse { - Log.e(TAG, "Failed to download audio for job $jobId", it) - return Result.failure(errorData(it.message ?: "Download failed")) - } - - val outputFile = outputFile(bookId, jobId) - outputFile.parentFile?.mkdirs() - outputFile.writeBytes(audioBytes) - - Log.i(TAG, "Saved TTS audio for job $jobId to ${outputFile.absolutePath}") - return Result.success( - successData( - jobId = jobId, - filePath = outputFile.absolutePath, - ) - ) - } - - job.isFailed -> { - Log.e(TAG, "TTS job $jobId failed: ${job.error}") - return Result.failure(errorData(job.error.ifBlank { "TTS job failed" })) - } - - else -> { - delay(pollIntervalMs) - } - } - } - - return Result.failure(errorData("TTS job polling timed out")) - } - - private fun outputFile(bookId: String, jobId: String): File { - val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64) - val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64) - val dir = File(applicationContext.cacheDir, "tts") - return File(dir, "${safeBookId}_${safeJobId}.m4b") - } - - companion object { - const val TAG = "TtsDownloadWorker" - - const val KEY_JOB_ID = "job_id" - const val KEY_BOOK_ID = "book_id" - const val KEY_MAX_ATTEMPTS = "max_attempts" - const val KEY_POLL_INTERVAL_MS = "poll_interval_ms" - - const val KEY_OUTPUT_FILE_PATH = "output_file_path" - const val KEY_OUTPUT_JOB_ID = "output_job_id" - const val KEY_ERROR_MESSAGE = "error_message" - - const val PROGRESS_STATUS = "progress_status" - const val PROGRESS_PROGRESS = "progress_progress" - const val PROGRESS_CURRENT_CHAPTER = "progress_current_chapter" - const val PROGRESS_COMPLETED_CHAPTERS = "progress_completed_chapters" - const val PROGRESS_TOTAL_CHAPTERS = "progress_total_chapters" - - private const val DEFAULT_MAX_ATTEMPTS = 360 - private const val DEFAULT_POLL_INTERVAL_MS = 5_000L - - fun createInputData( - jobId: String, - bookId: String, - maxAttempts: Int = DEFAULT_MAX_ATTEMPTS, - pollIntervalMs: Long = DEFAULT_POLL_INTERVAL_MS, - ): Data = - Data.Builder() - .putString(KEY_JOB_ID, jobId) - .putString(KEY_BOOK_ID, bookId) - .putInt(KEY_MAX_ATTEMPTS, maxAttempts) - .putLong(KEY_POLL_INTERVAL_MS, pollIntervalMs) - .build() - - private fun progressData( - jobId: String, - status: String, - progress: Double, - currentChapter: String, - completedChapters: Int, - totalChapters: Int, - ): Data = - Data.Builder() - .putString(KEY_JOB_ID, jobId) - .putString(PROGRESS_STATUS, status) - .putDouble(PROGRESS_PROGRESS, progress) - .putString(PROGRESS_CURRENT_CHAPTER, currentChapter) - .putInt(PROGRESS_COMPLETED_CHAPTERS, completedChapters) - .putInt(PROGRESS_TOTAL_CHAPTERS, totalChapters) - .build() - - private fun successData(jobId: String, filePath: String): Data = - Data.Builder() - .putString(KEY_OUTPUT_JOB_ID, jobId) - .putString(KEY_OUTPUT_FILE_PATH, filePath) - .build() - - private fun errorData(message: String): Data = - Data.Builder() - .putString(KEY_ERROR_MESSAGE, message) - .build() - } - } 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 120299a0..0993c05c 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,12 +35,9 @@ 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, - val audioCurrentFile: String = "", - val audioCurrentPosition: Long = 0L, val coverUrl: String = "", val lastSyncedAt: Long = 0L, ) : Parcelable { @@ -60,12 +57,9 @@ data class Book( remoteId = "", libraryId = "", mediaType = "", - itemType = "", hasAudio = false, hasEbook = false, audioDuration = 0L, - audioCurrentFile = "", - audioCurrentPosition = 0L, coverUrl = "", lastSyncedAt = 0L, ) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt deleted file mode 100644 index 2dec6b92..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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.player - -/** - * Placeholder for the audio player progress state. - * - * TODO: When implementing the ExoPlayer/MediaSession audio player, - * hook [org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase] - * into the playback position update flow, e.g.: - * - * ``` - * val book = ... - * val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile - * val position = player.currentPosition.coerceAtLeast(0L) - * val duration = player.duration.coerceAtLeast(book.audioDuration) - * syncPlaybackProgressUseCase(book, currentFile, position, duration) - * ``` - */ -data class AudioPlayerProgress( - val bookId: Int, - val currentFile: String, - val position: Long, - val duration: Long, -) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt deleted file mode 100644 index 1f7b57e6..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.dueattendant149.bookshelf.domain.model.player - -data class AudioTrack( - val fileId: String, - val title: String, - val durationMs: Long, - val order: Int, -) 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 deleted file mode 100644 index f1e3563e..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 deleted file mode 100644 index 75baba3e..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt +++ /dev/null @@ -1,173 +0,0 @@ -/* - * 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 deleted file mode 100644 index 8fe70e5b..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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 deleted file mode 100644 index bc9e0352..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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/model/tts/TtsModels.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt deleted file mode 100644 index eb3c447d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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.tts - -data class TtsEngine( - val id: String, - val name: String, - val supportsStreaming: Boolean, - val supportsCloning: Boolean, - val maxTextLength: Int, - val needsNetwork: Boolean, -) - -data class TtsVoice( - val id: String, - val name: String, - val language: String, - val engine: String, - val gender: String, - val quality: String, - val requiresReference: Boolean, -) - -data class TtsJob( - val jobId: String, - val bookId: String, - val title: String, - val author: String, - val engine: String, - val voiceId: String, - val speed: Double, - val status: String, - val progress: Double, - val currentChapter: String, - val totalChapters: Int, - val completedChapters: Int, - val outputPath: String, - val error: String, - val createdAt: Double, - val startedAt: Double, - val completedAt: Double, -) { - val isCompleted: Boolean get() = status == "completed" - val isFailed: Boolean get() = status == "failed" || status == "error" - val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending" -} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt deleted file mode 100644 index 8e48d6e7..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.dueattendant149.bookshelf.domain.repository - -import org.dueattendant149.bookshelf.domain.model.player.AudioTrack - -interface AudiobookshelfRepository { - suspend fun getAudioTracks(itemId: String): Result> -} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt index 1c57a972..35b71394 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt @@ -43,6 +43,4 @@ interface BookRepository { suspend fun getDefaultCover( book: Book ): Result - - suspend fun getBooksWithRemoteId(): Result> } \ No newline at end of file diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt deleted file mode 100644 index e9f67284..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.repository - -import org.dueattendant149.bookshelf.domain.model.library.Book - -interface ProgressRepository { - suspend fun syncReadingProgress( - book: Book, - lastChapter: String? = null, - ): Result - - suspend fun syncPlaybackProgress( - book: Book, - currentFile: String, - position: Long, - duration: Long, - ): Result - - suspend fun getBooksWithRemoteId(): Result> -} 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 1dda4aa7..7c9b8f7d 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,11 +6,6 @@ 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 @@ -18,8 +13,4 @@ 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/repository/RemoteTtsRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt deleted file mode 100644 index ba2ca7c1..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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.repository - -import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine -import org.dueattendant149.bookshelf.domain.model.tts.TtsJob -import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice - -interface RemoteTtsRepository { - suspend fun fetchEngines(): Result> - suspend fun fetchVoices(engine: String? = null): Result> - suspend fun createJob( - bookId: String, - engine: String, - voiceId: String, - speed: Double, - ): Result - - suspend fun getJob(jobId: String): Result - suspend fun downloadAudio(jobId: String): 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 deleted file mode 100644 index fdd2f6d8..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt +++ /dev/null @@ -1,380 +0,0 @@ -/* - * 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 deleted file mode 100644 index 371e3ddc..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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 deleted file mode 100644 index f28f148d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 deleted file mode 100644 index d521511b..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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/SyncPlaybackProgressUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt deleted file mode 100644 index b246974b..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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.domain.model.library.Book -import org.dueattendant149.bookshelf.domain.repository.ProgressRepository -import javax.inject.Inject - -class SyncPlaybackProgressUseCase - @Inject - constructor( - private val progressRepository: ProgressRepository, - ) { - suspend operator fun invoke( - book: Book, - currentFile: String, - position: Long, - duration: Long, - ): Result { - if (book.remoteId.isBlank()) { - return Result.success(Unit) - } - return progressRepository.syncPlaybackProgress( - book = book, - currentFile = currentFile, - position = position, - duration = duration, - ) - } - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt deleted file mode 100644 index 6187815c..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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.domain.model.library.Book -import org.dueattendant149.bookshelf.domain.repository.ProgressRepository -import javax.inject.Inject - -class SyncReadingProgressUseCase - @Inject - constructor( - private val progressRepository: ProgressRepository, - ) { - suspend operator fun invoke( - book: Book, - lastChapter: String? = null, - ): Result { - if (book.remoteId.isBlank()) { - return Result.success(Unit) - } - return progressRepository.syncReadingProgress(book, lastChapter) - } - } 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 deleted file mode 100644 index eb639d39..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt +++ /dev/null @@ -1,40 +0,0 @@ -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/use_case/tts/CreateTtsJobUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt deleted file mode 100644 index 3eb81d46..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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.tts - -import org.dueattendant149.bookshelf.domain.model.tts.TtsJob -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository -import javax.inject.Inject - -class CreateTtsJobUseCase - @Inject - constructor( - private val repository: RemoteTtsRepository, - ) { - suspend operator fun invoke( - bookId: String, - engine: String, - voiceId: String, - speed: Double, - ): Result = repository.createJob(bookId, engine, voiceId, speed) - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt deleted file mode 100644 index 5b18f029..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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.tts - -import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository -import javax.inject.Inject - -class FetchTtsEnginesUseCase - @Inject - constructor( - private val repository: RemoteTtsRepository, - ) { - suspend operator fun invoke(): Result> = repository.fetchEngines() - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt deleted file mode 100644 index db319bb9..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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.tts - -import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository -import javax.inject.Inject - -class FetchTtsVoicesUseCase - @Inject - constructor( - private val repository: RemoteTtsRepository, - ) { - suspend operator fun invoke(engine: String? = null): Result> = - repository.fetchVoices(engine) - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt deleted file mode 100644 index 3c80ed66..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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.tts - -import org.dueattendant149.bookshelf.domain.model.tts.TtsJob -import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository -import javax.inject.Inject - -class GetTtsJobUseCase - @Inject - constructor( - private val repository: RemoteTtsRepository, - ) { - suspend operator fun invoke(jobId: String): Result = repository.getJob(jobId) - } 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 403a88f8..b81b0c9c 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 @@ -20,19 +20,14 @@ fun String.isValidUri(): Boolean { /** * Returns true if the string starts with http:// or https:// (case-insensitive). */ -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+)?$") +fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX) /** - * Adds https:// (or http:// for bare IP addresses) if the string has no scheme. + * Adds https:// if the string has no http/https scheme. */ fun String.ensureUriScheme(): String { val trimmed = trim() - if (trimmed.hasUriScheme()) return trimmed - val hostPart = trimmed.substringBefore("/") - val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://" - return "$scheme$trimmed" + return if (trimmed.hasUriScheme()) trimmed else "https://$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 deleted file mode 100644 index 66efe568..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4c864971..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 deleted file mode 100644 index 9fa58b73..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 deleted file mode 100644 index df11ddf8..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 a8608d87..e338c542 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,9 +6,23 @@ package org.dueattendant149.bookshelf.presentation.bookshelf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor +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.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 @@ -16,26 +30,145 @@ 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( - 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 + 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) } + } + } + } } 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 c0002b32..c5aedec1 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,12 +12,9 @@ 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.ui.bookshelf.RemoteLibraryContent import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator @@ -40,15 +37,6 @@ object RemoteLibraryScreen : Screen, Parcelable { } } - RemoteLibraryContent( - model = model, - titleRes = R.string.bookshelf_screen, - onPlayAudio = { book -> - navigator.push(PlayerScreen(book)) - }, - navigateToTts = { bookRemoteId -> - navigator.push(TtsScreen(bookRemoteId)) - } - ) + RemoteLibraryContent(model = model) } } 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 deleted file mode 100644 index c3d73b7a..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt +++ /dev/null @@ -1,265 +0,0 @@ -/* - * 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 37d74ba5..0ca1a721 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,10 +24,13 @@ 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.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.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.navigator.NavigatorItem import org.dueattendant149.bookshelf.presentation.navigator.StackEvent import org.dueattendant149.bookshelf.presentation.settings.SettingsModel @@ -74,6 +77,11 @@ class MainActivity : AppCompatActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { + // Initializing Screen Models + val libraryModel = hiltViewModel() + val historyModel = hiltViewModel() + val browseModel = hiltViewModel() + SettingsEffects( effects = settingsModel.effects ) @@ -81,32 +89,32 @@ class MainActivity : AppCompatActivity() { ProvideSettings(settings) { val tabs = persistentListOf( NavigatorItem( - 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 + 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 ), NavigatorItem( - 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 + 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 ), NavigatorItem( - 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 + 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 ), NavigatorItem( - screen = SettingsScreen, - title = R.string.settings_screen, - tooltip = R.string.settings_screen, - selectedIcon = R.drawable.settings_tab_filled, - unselectedIcon = R.drawable.settings_tab_outlined + 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 ) ) @@ -121,7 +129,7 @@ class MainActivity : AppCompatActivity() { ) { Navigator( initialScreen = if (settings.showStartScreen.value) StartScreen - else BooksScreen, + else LibraryScreen, transitionSpec = { lastEvent -> when (lastEvent) { StackEvent.DEFAULT -> { @@ -137,14 +145,14 @@ class MainActivity : AppCompatActivity() { }, contentKey = { when (it) { - BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs" + LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs" else -> it } }, backHandlerEnabled = { it != StartScreen } - ) { screen -> + ) { screen -> when (screen) { - BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> { + LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> { 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 57f5ce70..8ec90827 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,8 +52,7 @@ class Navigator @AssistedInject constructor( popping: Boolean = false, saveInBackStack: Boolean = true ) { - val current = lastItem.value - if (current::class == targetScreen::class && current == targetScreen) return + if (lastItem.value::class == targetScreen::class) return if (!saveInBackStack) items.removeLast() changeStackEvent( @@ -61,6 +60,7 @@ 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 deleted file mode 100644 index e95be71e..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt +++ /dev/null @@ -1,104 +0,0 @@ -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 deleted file mode 100644 index 6dbc3f18..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt +++ /dev/null @@ -1,349 +0,0 @@ -/* - * 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.player - -import android.content.Context -import android.content.Intent -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -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 - @Inject - constructor( - @ApplicationContext private val context: Context, - private val serverSettings: ServerSettings, - private val audiobookshelfRepository: AudiobookshelfRepository, - 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() - - private val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - _state.update { it.copy(isPlaying = isPlaying) } - } - - override fun onPlaybackStateChanged(playbackState: Int) { - _state.update { - it.copy( - isLoading = playbackState == Player.STATE_BUFFERING, - durationMs = exoPlayer.duration.coerceAtLeast(0L), - ) - } - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int, - ) { - _state.update { - it.copy( - currentTrackIndex = exoPlayer.currentMediaItemIndex, - currentPositionMs = newPosition.positionMs.coerceAtLeast(0L), - durationMs = exoPlayer.duration.coerceAtLeast(0L), - ) - } - } - - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - _state.update { - it.copy( - currentTrackIndex = exoPlayer.currentMediaItemIndex, - durationMs = exoPlayer.duration.coerceAtLeast(0L), - currentPositionMs = 0L, - ) - } - } - - override fun onPlayerError(error: PlaybackException) { - _state.update { it.copy(error = error.localizedMessage, isLoading = false) } - } - } - - init { - startProgressUpdates() - } - - 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 { - authProvider.refresh(serverSettings) - - val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme() - if (baseUrl.isNullOrBlank()) { - _state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") } - return@launch - } - - val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId) - tracksResult.fold( - onSuccess = { tracks -> - if (tracks.isEmpty()) { - _state.update { it.copy(isLoading = false, error = "No audio files found") } - return@fold - } - _state.update { it.copy(tracks = 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 -> - _state.update { it.copy(isLoading = false, error = error.message) } - } - ) - } - } - - 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 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) - .setMediaMetadata( - androidx.media3.common.MediaMetadata.Builder() - .setTitle(track.title) - .setAlbumTitle(_state.value.book.title) - .setArtist(_state.value.book.author.getAsString()) - .build() - ) - .build() - } - - exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs) - exoPlayer.prepare() - exoPlayer.play() - } - - private var lastSyncTimeMs: Long = 0L - private var lastSavedPositionMs: Long = 0L - - private fun startProgressUpdates() { - viewModelScope.launch { - while (isActive) { - if (exoPlayer.isPlaying || exoPlayer.isLoading) { - val pos = exoPlayer.currentPosition.coerceAtLeast(0L) - val dur = exoPlayer.duration.coerceAtLeast(0L) - val trackIndex = exoPlayer.currentMediaItemIndex - _state.update { - it.copy( - currentTrackIndex = trackIndex, - currentPositionMs = pos, - bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L), - durationMs = dur, - ) - } - - val now = System.currentTimeMillis() - if (now - lastSyncTimeMs >= 30_000L && pos != lastSavedPositionMs) { - lastSyncTimeMs = now - lastSavedPositionMs = pos - persistPlaybackProgress(pos, dur) - } - } - delay(500) - } - } - } - - private fun persistPlaybackProgress(positionMs: Long, durationMs: Long) { - val book = _state.value.book - if (book.remoteId.isBlank()) return - val currentFile = _state.value.tracks.getOrNull(exoPlayer.currentMediaItemIndex)?.fileId - ?: book.audioCurrentFile - - viewModelScope.launch { - val updated = book.copy( - audioCurrentFile = currentFile, - audioCurrentPosition = positionMs, - audioDuration = if (durationMs > 0) durationMs else book.audioDuration, - ) - database.bookDao.updateBook(bookMapper.toBookEntity(updated)) - - syncPlaybackProgressUseCase( - book = updated, - currentFile = currentFile, - position = positionMs, - duration = updated.audioDuration, - ) - } - } - - private fun startService(book: Book) { - val intent = Intent(context, AudioPlaybackService::class.java).apply { - putExtra(AudioPlaybackService.EXTRA_BOOK, book) - } - context.startForegroundService(intent) - } - - fun playPause() { - if (exoPlayer.isPlaying) { - exoPlayer.pause() - } else { - exoPlayer.play() - } - } - - fun seekTo(positionMs: Long) { - exoPlayer.seekTo(positionMs.coerceIn(0L, exoPlayer.duration.coerceAtLeast(0L))) - } - - fun seekForward(ms: Long = 30_000L) { - seekTo(exoPlayer.currentPosition + ms) - } - - fun seekBackward(ms: Long = 10_000L) { - seekTo(exoPlayer.currentPosition - ms) - } - - fun skipToNext() { - if (exoPlayer.hasNextMediaItem()) { - exoPlayer.seekToNextMediaItem() - } - } - - fun skipToPrevious() { - if (exoPlayer.hasPreviousMediaItem()) { - exoPlayer.seekToPreviousMediaItem() - } - } - - fun setSpeed(speed: Float) { - val clamped = speed.coerceIn(0.5f, 2.0f) - exoPlayer.setPlaybackSpeed(clamped) - _state.update { it.copy(playbackSpeed = clamped) } - } - - fun selectTrack(index: Int) { - if (index in 0 until exoPlayer.mediaItemCount) { - exoPlayer.seekToDefaultPosition(index) - } - } - - 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() - exoPlayer.clearMediaItems() - context.stopService(Intent(context, AudioPlaybackService::class.java)) - super.onCleared() - } - } 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 deleted file mode 100644 index 51380c92..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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.player - -import android.os.Parcelable -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.hilt.navigation.compose.hiltViewModel -import kotlinx.parcelize.Parcelize -import org.dueattendant149.bookshelf.domain.model.library.Book -import org.dueattendant149.bookshelf.presentation.navigator.Screen -import org.dueattendant149.bookshelf.ui.player.PlayerContent - -@Parcelize -data class PlayerScreen( - val book: Book, - val startTrackIndex: Int = 0, -) : Screen, Parcelable { - - @Composable - override fun Content() { - val model = hiltViewModel() - - LaunchedEffect(book, startTrackIndex) { - model.init(book, startTrackIndex) - } - - DisposableEffect(Unit) { - onDispose { - // ExoPlayer lifecycle is tied to the ViewModel; no extra cleanup needed here. - } - } - - val navigator = org.dueattendant149.bookshelf.ui.navigator.LocalNavigator.current - PlayerContent( - state = model.state, - onPlayPause = model::playPause, - onSeek = model::seekTo, - onSeekForward = model::seekForward, - onSeekBackward = model::seekBackward, - onSkipNext = model::skipToNext, - 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 deleted file mode 100644 index c225fd50..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.dueattendant149.bookshelf.presentation.player - -import org.dueattendant149.bookshelf.domain.model.library.Book -import org.dueattendant149.bookshelf.domain.model.player.AudioTrack - -data class PlayerState( - val book: Book = Book.default, - val tracks: List = emptyList(), - 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, - val playbackSpeed: Float = 1f, - val error: String? = null, -) 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 98c36c6c..229d26f9 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,8 +38,4 @@ 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 6bb8b711..04e16265 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,14 +40,6 @@ 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() @@ -86,8 +78,4 @@ 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 e6436ea5..c045f8bd 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,8 +37,6 @@ 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 import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint @@ -52,9 +50,7 @@ class ReaderModel @Inject constructor( private val getTextUseCase: GetTextUseCase, private val getBookUseCase: GetBookUseCase, private val getHistoryForBookUseCase: GetHistoryForBookUseCase, - private val getChapterProgressUseCase: GetChapterProgressUseCase, - private val syncReadingProgressUseCase: SyncReadingProgressUseCase, - private val paginateReaderTextUseCase: PaginateReaderTextUseCase, + private val getChapterProgressUseCase: GetChapterProgressUseCase ) : ViewModel() { private val mutex = Mutex() @@ -98,9 +94,7 @@ class ReaderModel @Inject constructor( book = it.book.copy( lastOpened = lastOpened ), - text = text, - pages = emptyList(), - currentPage = 0 + text = text ) } ensureActive() @@ -115,34 +109,24 @@ class ReaderModel @Inject constructor( } is ReaderEvent.OnRestoreScroll -> { - 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 } + snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 } - state.listState.requestScrollToItem( - index = state.book.scrollIndex, - scrollOffset = state.book.scrollOffset + _state.value.listState.requestScrollToItem( + index = _state.value.book.scrollIndex, + scrollOffset = _state.value.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 - ) - } } } @@ -193,10 +177,6 @@ class ReaderModel @Inject constructor( } updateBookUseCase(_state.value.book) - syncReadingProgressUseCase( - _state.value.book, - _state.value.currentChapter?.title, - ) LibraryScreen.refreshListChannel.trySend(300) HistoryScreen.refreshListChannel.trySend(300) @@ -244,69 +224,14 @@ class ReaderModel @Inject constructor( scrollJob = viewModelScope.launch(Dispatchers.IO) { delay(300) - 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)) - } - } - } + val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt() - 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 + _state.value.listState.requestScrollToItem( + index = scrollTo, + scrollOffset = 0 ) - _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) } + onEvent(ReaderEvent.OnUpdateChapter(scrollTo)) } } @@ -361,10 +286,6 @@ class ReaderModel @Inject constructor( } updateBookUseCase(_state.value.book) - syncReadingProgressUseCase( - _state.value.book, - _state.value.currentChapter?.title, - ) LibraryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0) @@ -453,10 +374,6 @@ 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) } } @@ -531,10 +448,6 @@ class ReaderModel @Inject constructor( } updateBookUseCase(_state.value.book) - syncReadingProgressUseCase( - _state.value.book, - _state.value.currentChapter?.title, - ) LibraryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0) @@ -557,31 +470,22 @@ class ReaderModel @Inject constructor( } ?: (-1 to -1) } - private fun calculateProgress(pageOrIndex: Int? = null): Float { - val state = _state.value + private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float { if ( - state.isLoading || - state.text.isEmpty() || - state.errorMessage != null - ) return state.book.progress + _state.value.isLoading || + _state.value.listState.layoutInfo.totalItemsCount == 0 || + _state.value.text.isEmpty() || + _state.value.errorMessage != null + ) return _state.value.book.progress - 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() - } + if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f - if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress + val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index + if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f - 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() + return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) + .div(_state.value.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 b142d758..68efbb25 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,7 +8,6 @@ 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 @@ -24,29 +23,19 @@ 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 @@ -66,7 +55,6 @@ 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 @@ -95,11 +83,6 @@ 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 { @@ -172,9 +155,76 @@ 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 } @@ -237,158 +287,6 @@ 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 } @@ -438,9 +336,6 @@ 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( @@ -477,45 +372,11 @@ 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() @@ -532,44 +393,32 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { fullscreen = settings.fullscreen.value ) - 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, + 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, horizontalGesture = settings.horizontalGesture.value, horizontalGestureScroll = settings.horizontalGestureScroll.value, horizontalGestureSensitivity = horizontalGestureSensitivity, @@ -621,9 +470,7 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable { showChaptersDrawer = screenModel::onEvent, dismissDrawer = screenModel::onEvent, navigateBack = screenModel::onEvent, - navigateToBookInfo = screenModel::onEvent, - navigateToRsvp = screenModel::onEvent + navigateToBookInfo = 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 60aa8023..e46aecd0 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,7 +12,6 @@ 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 @@ -21,8 +20,6 @@ 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 deleted file mode 100644 index 5635f394..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt +++ /dev/null @@ -1,11 +0,0 @@ -/* - * 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 deleted file mode 100644 index 6ea608be..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * 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 deleted file mode 100644 index 3f20b981..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * 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 deleted file mode 100644 index c5fa954d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * 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 deleted file mode 100644 index 52d2ee84..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt +++ /dev/null @@ -1,18 +0,0 @@ -/* - * 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 deleted file mode 100644 index 1237272d..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * 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 deleted file mode 100644 index 61847a64..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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/BookshelfSettingsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt index 365cf4b8..ccee3aba 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt @@ -18,8 +18,6 @@ import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl -import org.dueattendant149.bookshelf.domain.util.ensureUriScheme -import org.dueattendant149.bookshelf.domain.util.normalizeUri import javax.inject.Inject data class BookshelfSettingsState( @@ -110,20 +108,8 @@ class BookshelfSettingsModel fun save() { viewModelScope.launch { val current = _state.value - val normalizedBookshelfUrl = current.bookshelfUrl.trim().let { - if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it - } - val normalizedAbsUrl = current.absUrl.trim().let { - if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it - } - _state.update { - it.copy( - bookshelfUrl = normalizedBookshelfUrl, - absUrl = normalizedAbsUrl, - ) - } - serverSettings.setBookshelfUrl(normalizedBookshelfUrl) - serverSettings.setAbsUrl(normalizedAbsUrl) + serverSettings.setBookshelfUrl(current.bookshelfUrl) + serverSettings.setAbsUrl(current.absUrl) serverSettings.setAbsToken(current.absToken) } } 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 f25ed13b..8fd8afcf 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,7 +10,6 @@ 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 @@ -30,12 +29,9 @@ 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/presentation/tts/TtsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt deleted file mode 100644 index 631f96cd..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * 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.tts - -import android.app.Application -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.work.Constraints -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import org.dueattendant149.bookshelf.data.worker.TtsDownloadWorker -import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine -import org.dueattendant149.bookshelf.domain.model.tts.TtsJob -import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice -import org.dueattendant149.bookshelf.domain.use_case.tts.CreateTtsJobUseCase -import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsEnginesUseCase -import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsVoicesUseCase -import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase -import org.dueattendant149.bookshelf.R -import javax.inject.Inject - -data class TtsState( - val engines: List = emptyList(), - val voices: List = emptyList(), - val selectedEngine: TtsEngine? = null, - val selectedVoice: TtsVoice? = null, - val speed: Double = 1.0, - val job: TtsJob? = null, - val isLoading: Boolean = false, - val isCreatingJob: Boolean = false, - val error: String? = null, - val outputFilePath: String? = null, -) - -@HiltViewModel -class TtsModel - @Inject - constructor( - private val application: Application, - private val fetchTtsEnginesUseCase: FetchTtsEnginesUseCase, - private val fetchTtsVoicesUseCase: FetchTtsVoicesUseCase, - private val createTtsJobUseCase: CreateTtsJobUseCase, - private val getTtsJobUseCase: GetTtsJobUseCase, - ) : ViewModel() { - - private val _state = MutableStateFlow(TtsState()) - val state = _state.asStateFlow() - - private val workManager = WorkManager.getInstance(application) - - private var observedWork: LiveData? = null - private var workObserver: Observer? = null - - fun load(bookRemoteId: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - - val enginesResult = fetchTtsEnginesUseCase() - val engines = enginesResult.getOrDefault(emptyList()) - - val selectedEngine = engines.firstOrNull() - val voicesResult = selectedEngine?.let { fetchTtsVoicesUseCase(it.id) } - ?: fetchTtsVoicesUseCase() - val voices = voicesResult?.getOrDefault(emptyList()) ?: emptyList() - - _state.update { - it.copy( - isLoading = false, - engines = engines, - voices = voices, - selectedEngine = selectedEngine, - selectedVoice = voices.firstOrNull(), - error = enginesResult.exceptionOrNull()?.message - ?: voicesResult?.exceptionOrNull()?.message, - ) - } - } - } - - fun selectEngine(engine: TtsEngine) { - _state.update { it.copy(selectedEngine = engine, selectedVoice = null) } - viewModelScope.launch { - _state.update { it.copy(isLoading = true, error = null) } - val result = fetchTtsVoicesUseCase(engine.id) - _state.update { - it.copy( - isLoading = false, - voices = result.getOrDefault(emptyList()), - selectedVoice = result.getOrDefault(emptyList()).firstOrNull(), - error = result.exceptionOrNull()?.message, - ) - } - } - } - - fun selectVoice(voice: TtsVoice) { - _state.update { it.copy(selectedVoice = voice) } - } - - fun updateSpeed(speed: Double) { - _state.update { it.copy(speed = speed.coerceIn(0.5, 2.0)) } - } - - fun createJob(bookRemoteId: String) { - val engine = _state.value.selectedEngine ?: return - val voice = _state.value.selectedVoice ?: return - - viewModelScope.launch { - _state.update { it.copy(isCreatingJob = true, error = null, job = null, outputFilePath = null) } - val result = createTtsJobUseCase( - bookId = bookRemoteId, - engine = engine.id, - voiceId = voice.id, - speed = _state.value.speed, - ) - val job = result.getOrElse { - _state.update { it.copy(isCreatingJob = false, error = it.error ?: application.getString(R.string.tts_create_job_failed)) } - return@launch - } - _state.update { it.copy(isCreatingJob = false, job = job) } - enqueueDownloadWorker(job, bookRemoteId) - } - } - - fun refreshJob(jobId: String) { - viewModelScope.launch { - val result = getTtsJobUseCase(jobId) - _state.update { - it.copy( - job = result.getOrNull() ?: it.job, - error = result.exceptionOrNull()?.message ?: it.error, - ) - } - } - } - - private fun enqueueDownloadWorker(job: TtsJob, bookRemoteId: String) { - val request = OneTimeWorkRequestBuilder() - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .setInputData( - TtsDownloadWorker.createInputData( - jobId = job.jobId, - bookId = bookRemoteId, - ) - ) - .build() - - workManager.enqueueUniqueWork( - "tts_download_${job.jobId}", - ExistingWorkPolicy.KEEP, - request, - ) - - observeWorker(request.id) - } - - private fun observeWorker(workId: java.util.UUID) { - workObserver?.let { observer -> - observedWork?.removeObserver(observer) - } - - val liveData = workManager.getWorkInfoByIdLiveData(workId) - val observer: Observer = Observer { info -> - info ?: return@Observer - when (info.state) { - WorkInfo.State.SUCCEEDED -> { - val filePath = info.outputData.getString(TtsDownloadWorker.KEY_OUTPUT_FILE_PATH) - _state.update { it.copy(outputFilePath = filePath) } - } - - WorkInfo.State.FAILED -> { - val message = info.outputData.getString(TtsDownloadWorker.KEY_ERROR_MESSAGE) - _state.update { it.copy(error = message ?: application.getString(R.string.tts_download_failed)) } - } - - WorkInfo.State.RUNNING -> { - val progress = info.progress.getDouble(TtsDownloadWorker.PROGRESS_PROGRESS, 0.0) - val status = info.progress.getString(TtsDownloadWorker.PROGRESS_STATUS) ?: "" - _state.update { - it.copy( - job = it.job?.copy(status = status, progress = progress) - ) - } - } - - else -> {} - } - } - - observedWork = liveData - workObserver = observer - liveData.observeForever(observer) - } - - override fun onCleared() { - workObserver?.let { observer -> - observedWork?.removeObserver(observer) - } - super.onCleared() - } - } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt deleted file mode 100644 index 680ee0a5..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.tts - -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.navigator.LocalNavigator -import org.dueattendant149.bookshelf.ui.tts.TtsContent - -@Parcelize -data class TtsScreen(val bookRemoteId: String) : Screen, Parcelable { - - @Composable - override fun Content() { - val model = hiltViewModel() - val navigator = LocalNavigator.current - TtsContent( - model = model, - bookRemoteId = bookRemoteId, - navigateBack = navigator::pop, - ) - } -} 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 87b3b8d3..69764fac 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,25 +8,35 @@ 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.WindowInsets +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.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.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 @@ -40,62 +50,35 @@ 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.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.presentation.bookshelf.RemoteLibraryModel 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: RemoteLibraryViewModel, - titleRes: Int, - onPlayAudio: (Book) -> Unit, - navigateToTts: (bookRemoteId: String) -> Unit, -) { +fun RemoteLibraryContent(model: RemoteLibraryModel) { val state by model.state.collectAsStateWithLifecycle() - val navigator = LocalNavigator.current val refreshState = rememberPullRefreshState( refreshing = state.isLoading, onRefresh = model::loadLibraries ) - 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 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 onlineIndicator = remember(state.serverOnline) { - if (state.serverOnline) "" - else " • Offline" - } - Scaffold( - contentWindowInsets = WindowInsets(0), topBar = { TopAppBar( - title = { - Text( - text = stringResource(titleRes) + onlineIndicator, - style = MaterialTheme.typography.titleLarge - ) - } + title = { Text(stringResource(R.string.bookshelf_screen)) } ) } ) { padding -> @@ -110,71 +93,95 @@ fun RemoteLibraryContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - items( - items = visibleBooks, - key = { it.remoteId } - ) { book -> - val cacheStatus = state.cacheStatuses[book.remoteId] - val isCaching = state.cachingBookId == book.remoteId - - 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, - progress = progress, - primaryButton = { - if (primaryLabel.isNotBlank()) { - PrimaryActionButton( - label = primaryLabel, - enabled = !isCaching, - onClick = onPrimaryClick - ) - } - }, - secondaryButton = { - if (secondaryLabel.isNotBlank()) { - SecondaryActionButton( - label = secondaryLabel, - onClick = onSecondaryClick - ) - } - } + item { + Text( + text = stringResource(R.string.bookshelf_libraries_header), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp) ) } - if (state.isLoading && state.books.isEmpty()) { + 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] + val cacheStatus = state.cacheStatuses[book.remoteId] + val isCaching = state.cachingBookId == book.remoteId + BookListItem( + book = book, + cacheStatus = cacheStatus, + isCaching = isCaching, + onDownload = { model.cacheBook(book) }, + onDelete = { model.deleteCache(book) }, + onRead = if (book.hasEbook) ({ model.openBook(book) }) else null + ) + } + + if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) { item { Box( modifier = Modifier @@ -188,7 +195,7 @@ fun RemoteLibraryContent( } } - if (!state.isLoading && state.books.isEmpty() && state.error == null) { + if (!state.isLoading && state.libraries.isEmpty() && state.error == null) { EmptyPlaceholder( modifier = Modifier.align(Alignment.Center), message = stringResource(R.string.bookshelf_empty_message), @@ -198,7 +205,6 @@ fun RemoteLibraryContent( ) } - state.error?.let { error -> ErrorPlaceholder( modifier = Modifier.align(Alignment.Center), @@ -220,4 +226,102 @@ fun RemoteLibraryContent( } } +@Composable +private fun BookListItem( + book: Book, + cacheStatus: CacheStatus?, + isCaching: Boolean, + onDownload: () -> Unit, + onDelete: () -> Unit, + onRead: (() -> 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 (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), + ) + } + + onRead?.let { read -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp), + horizontalArrangement = Arrangement.End + ) { + Button( + onClick = read, + enabled = !isCaching + ) { + Text(stringResource(R.string.read)) + } + } + } + } + } +} + +@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 deleted file mode 100644 index 05d6e94c..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index b5cdce77..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index bc70b256..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt +++ /dev/null @@ -1,272 +0,0 @@ -/* - * 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.player - -import androidx.compose.foundation.layout.Arrangement -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.size -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 -import androidx.compose.material.icons.filled.SkipPrevious -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.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -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.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.flow.StateFlow -import org.dueattendant149.bookshelf.R -import org.dueattendant149.bookshelf.presentation.player.PlayerState -import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun PlayerContent( - state: StateFlow, - onPlayPause: () -> Unit, - onSeek: (Long) -> Unit, - onSeekForward: () -> Unit, - onSeekBackward: () -> Unit, - onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSetSpeed: (Float) -> Unit, - onSelectTrack: (Int) -> Unit, - onOpenChapters: () -> Unit, - onDownloadAudiobook: () -> Unit, -) { - val playerState by state.collectAsStateWithLifecycle() - val navigator = LocalNavigator.current - - Scaffold( - topBar = { - TopAppBar( - title = { Text(playerState.book.title) }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.go_back_content_desc) - ) - } - } - ) - } - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - text = playerState.book.author.getAsString() ?: "", - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - val currentTrack = playerState.tracks.getOrNull(playerState.currentTrackIndex) - Text( - text = currentTrack?.title ?: stringResource(R.string.player_no_track), - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 8.dp) - ) - - if (playerState.isLoading) { - CircularProgressIndicator() - } - - playerState.error?.let { error -> - Text( - text = error, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - val duration = playerState.durationMs.coerceAtLeast(1L) - 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 }, - onValueChangeFinished = { - onSeek((sliderPosition * duration).roundToInt().toLong()) - }, - modifier = Modifier.fillMaxWidth(), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(formatMs(playerState.currentPositionMs)) - Text(formatMs(playerState.durationMs)) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - IconButton(onClick = onSeekBackward) { - Text(stringResource(R.string.player_seek_backward)) - } - IconButton(onClick = onSkipPrevious) { - Icon(Icons.Default.SkipPrevious, contentDescription = stringResource(R.string.player_previous)) - } - IconButton( - onClick = onPlayPause, - modifier = Modifier.size(64.dp) - ) { - Icon( - imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = stringResource( - if (playerState.isPlaying) R.string.player_pause else R.string.player_play - ), - modifier = Modifier.size(48.dp) - ) - } - IconButton(onClick = onSkipNext) { - Icon(Icons.Default.SkipNext, contentDescription = stringResource(R.string.player_next)) - } - IconButton(onClick = onSeekForward) { - Text(stringResource(R.string.player_seek_forward)) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - listOf(0.5f, 1f, 1.25f, 1.5f, 2f).forEach { speed -> - TextButton( - onClick = { onSetSpeed(speed) }, - ) { - Text( - text = "${speed}x", - color = if (playerState.playbackSpeed == speed) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - } - ) - } - } - } - - 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( - items = playerState.tracks, - key = { _, track -> track.fileId } - ) { index, track -> - val selected = index == playerState.currentTrackIndex - Card( - onClick = { onSelectTrack(index) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = "${index + 1}. ${track.title}", - color = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(16.dp) - ) - } - } - } - } - } -} - -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/ui/reader/ReaderBottomBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt index d43a0d6f..214f0847 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,7 +50,6 @@ 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( @@ -61,10 +60,6 @@ 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 @@ -143,17 +138,13 @@ fun ReaderBottomBar( } }, slider = { - ReaderBottomBarSlider( - book = book, - lockMenu = lockMenu, - listState = listState, - readerPagination = readerPagination, - pages = pages, - currentPage = currentPage, - onChangePage = onChangePage, - scroll = scroll, - changeProgress = changeProgress - ) + ReaderBottomBarSlider( + book = book, + lockMenu = lockMenu, + listState = listState, + scroll = scroll, + changeProgress = changeProgress + ) }, indicator = { ReaderBottomBarCheckpointsIndicator( @@ -249,10 +240,6 @@ 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 ) { @@ -268,17 +255,7 @@ private fun ReaderBottomBarSlider( value = progress, enabled = !lockMenu, onValueChange = { - 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) { + 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 f05326e4..64a38678 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,10 +58,6 @@ fun ReaderContent( checkpoints: List, showMenu: Boolean, lockMenu: Boolean, - readerPagination: Boolean, - pages: List, - currentPage: Int, - onPageChanged: (Int) -> Unit, contentPadding: PaddingValues, verticalPadding: Dp, horizontalGesture: ReaderHorizontalGesture, @@ -115,7 +111,6 @@ fun ReaderContent( showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, - navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit, navigateBack: (ReaderEvent.OnNavigateBack) -> Unit ) { ReaderBottomSheet( @@ -125,33 +120,29 @@ 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, - readerPagination = readerPagination, - pages = pages, - currentPage = currentPage, - onPageChanged = onPageChanged, - 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, + contentPadding = contentPadding, + verticalPadding = verticalPadding, horizontalGesture = horizontalGesture, horizontalGestureScroll = horizontalGestureScroll, horizontalGestureSensitivity = horizontalGestureSensitivity, @@ -200,8 +191,7 @@ fun ReaderContent( showSettingsBottomSheet = showSettingsBottomSheet, showChaptersDrawer = showChaptersDrawer, navigateBack = navigateBack, - navigateToBookInfo = navigateToBookInfo, - navigateToRsvp = navigateToRsvp + navigateToBookInfo = navigateToBookInfo ) } 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 f1efb53b..1527bc94 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,7 +19,6 @@ 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 @@ -184,10 +183,6 @@ 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 c13c374d..fd94325f 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,18 +21,13 @@ 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 @@ -56,10 +51,6 @@ 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, @@ -106,9 +97,6 @@ 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) { @@ -151,7 +139,7 @@ fun ReaderLayout( .fillMaxSize() .background(backgroundColor) .then( - if (!isLoading && toolbarHidden && !readerPagination) { + if (!isLoading && toolbarHidden) { Modifier.noRippleClickable( onClick = { menuVisibility( @@ -166,140 +154,90 @@ fun ReaderLayout( ) .padding(contentPadding) .padding(vertical = verticalPadding) - .then( - if (!readerPagination) { - Modifier.readerHorizontalGesture( - listState = listState, - horizontalGesture = horizontalGesture, - horizontalGestureScroll = horizontalGestureScroll, - horizontalGestureSensitivity = horizontalGestureSensitivity, - horizontalGestureAlphaAnim = horizontalGestureAlphaAnim, - horizontalGesturePullAnim = horizontalGesturePullAnim, - isLoading = isLoading - ) - } else Modifier + .readerHorizontalGesture( + listState = listState, + horizontalGesture = horizontalGesture, + horizontalGestureScroll = horizontalGestureScroll, + horizontalGestureSensitivity = horizontalGestureSensitivity, + horizontalGestureAlphaAnim = horizontalGestureAlphaAnim, + horizontalGesturePullAnim = horizontalGesturePullAnim, + isLoading = isLoading ) ) { - 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 + 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), ) - } 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) + ) { + 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 705cec4f..808646bf 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,6 +6,7 @@ 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 @@ -22,7 +23,7 @@ import org.dueattendant149.bookshelf.ui.reader.model.FontWithName import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable -fun ReaderLayoutText( +fun LazyItemScope.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 b2d24f87..c34f99ed 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,6 +11,7 @@ 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 @@ -25,7 +26,7 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme import org.dueattendant149.bookshelf.ui.common.components.common.StyledText @Composable -fun ReaderLayoutTextChapter( +fun LazyItemScope.ReaderLayoutTextChapter( chapter: Chapter, chapterTitleAlignment: ReaderTextAlignment, fontColor: Color, @@ -35,6 +36,10 @@ fun 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 2fccc005..f6d5b1e9 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,6 +10,7 @@ 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 @@ -21,7 +22,7 @@ import org.dueattendant149.bookshelf.domain.model.reader.ReaderText import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment @Composable -fun ReaderLayoutTextImage( +fun LazyItemScope.ReaderLayoutTextImage( entry: ReaderText.Image, sidePadding: Dp, imagesCornersRoundness: Dp, @@ -31,6 +32,10 @@ fun 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 c69f4bbd..1320c793 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,12 +10,16 @@ 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 @@ -25,10 +29,9 @@ 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 ReaderLayoutTextParagraph( +fun LazyItemScope.ReaderLayoutTextParagraph( paragraph: Text, showMenu: Boolean, fontFamily: FontWithName, @@ -49,20 +52,9 @@ fun 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, @@ -92,7 +84,18 @@ fun ReaderLayoutTextParagraph( ) } else Modifier ), - style = style, + 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 + ), 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 e630de25..be3220ee 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,6 +7,7 @@ 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 @@ -17,13 +18,17 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @Composable -fun ReaderLayoutTextSeparator( +fun LazyItemScope.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 deleted file mode 100644 index 2fa65363..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt +++ /dev/null @@ -1,276 +0,0 @@ -/* - * 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 c5bd2c03..31bd411e 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,13 +62,8 @@ 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, @@ -117,7 +112,6 @@ fun ReaderScaffold( showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit, showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, - navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit, navigateBack: (ReaderEvent.OnNavigateBack) -> Unit ) { Scaffold( @@ -143,8 +137,7 @@ fun ReaderScaffold( showSettingsBottomSheet = showSettingsBottomSheet, showChaptersDrawer = showChaptersDrawer, navigateBack = navigateBack, - navigateToBookInfo = navigateToBookInfo, - navigateToRsvp = navigateToRsvp + navigateToBookInfo = navigateToBookInfo ) } }, @@ -163,10 +156,6 @@ fun ReaderScaffold( lockMenu = lockMenu, checkpoints = checkpoints, bottomBarPadding = bottomBarPadding, - readerPagination = readerPagination, - pages = pages, - currentPage = currentPage, - onChangePage = onPageChanged, restoreCheckpoint = restoreCheckpoint, scroll = scroll, changeProgress = changeProgress @@ -176,10 +165,6 @@ 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 deleted file mode 100644 index 7d0ce7ac..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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 587bce24..fb6a3d9e 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,7 +14,6 @@ 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 @@ -54,7 +53,6 @@ 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 @@ -140,15 +138,6 @@ 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 deleted file mode 100644 index 080b1951..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index c945006f..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt +++ /dev/null @@ -1,420 +0,0 @@ -/* - * 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 deleted file mode 100644 index 4fd399ab..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt +++ /dev/null @@ -1,328 +0,0 @@ -/* - * 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 d27b6e7c..f8a2e12f 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,7 +16,6 @@ import androidx.compose.runtime.Composable fun SettingsContent( listState: LazyListState, scrollBehavior: TopAppBarScrollBehavior, - isRoot: Boolean = false, navigateToGeneralSettings: () -> Unit, navigateToAppearanceSettings: () -> Unit, navigateToReaderSettings: () -> Unit, @@ -29,7 +28,6 @@ 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 108ff2e4..015a9164 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,7 +24,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll fun SettingsScaffold( listState: LazyListState, scrollBehavior: TopAppBarScrollBehavior, - isRoot: Boolean = false, navigateToGeneralSettings: () -> Unit, navigateToAppearanceSettings: () -> Unit, navigateToReaderSettings: () -> Unit, @@ -43,7 +42,6 @@ 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 8e03826b..517833be 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,7 +24,6 @@ import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton @Composable fun SettingsTopBar( scrollBehavior: TopAppBarScrollBehavior, - isRoot: Boolean = false, navigateToStart: () -> Unit, navigateBack: () -> Unit ) { @@ -33,11 +32,9 @@ fun SettingsTopBar( StyledText(stringResource(id = R.string.settings_screen)) }, navigationIcon = { - if (!isRoot) { - NavigatorBackIconButton( - navigateBack = navigateBack - ) - } + NavigatorBackIconButton( + navigateBack = navigateBack + ) }, actions = { IconButton( diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt index c07c7e73..d3dc34fb 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt @@ -73,7 +73,7 @@ fun BookshelfSettingsContent( value = state.bookshelfUrl, onValueChange = model::updateBookshelfUrl, label = { Text(stringResource(R.string.bookshelf_url_option)) }, - placeholder = { Text("https://tts.dueattendant149.org") }, + placeholder = { Text("http://bookshelf-api:8073") }, supportingText = { Text(stringResource(R.string.bookshelf_url_option_desc)) }, modifier = Modifier.fillMaxWidth() ) @@ -83,7 +83,7 @@ fun BookshelfSettingsContent( value = state.absUrl, onValueChange = model::updateAbsUrl, label = { Text(stringResource(R.string.abs_url_option)) }, - placeholder = { Text("https://books.dueattendant149.org") }, + placeholder = { Text("http://192.168.1.119:13378") }, supportingText = { Text(stringResource(R.string.abs_url_option_desc)) }, modifier = Modifier.fillMaxWidth() ) 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 a65d5ad1..706f3f16 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,7 +21,6 @@ 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 }, @@ -35,10 +34,6 @@ 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 deleted file mode 100644 index f409b042..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 0e445da1..17e5ad84 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,12 +26,6 @@ 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 }, @@ -88,29 +82,5 @@ 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 deleted file mode 100644 index f0254313..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 deleted file mode 100644 index ad47fc49..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * 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 deleted file mode 100644 index 393df5b1..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt deleted file mode 100644 index f36b7142..00000000 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt +++ /dev/null @@ -1,247 +0,0 @@ -/* - * 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.tts - -import androidx.compose.foundation.layout.Arrangement -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.lazy.LazyColumn -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -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.ui.Modifier -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.domain.model.tts.TtsEngine -import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice -import org.dueattendant149.bookshelf.presentation.tts.TtsModel -import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TtsContent( - model: TtsModel, - bookRemoteId: String, - navigateBack: () -> Unit, -) { - val state by model.state.collectAsStateWithLifecycle() - - LaunchedEffect(bookRemoteId) { - model.load(bookRemoteId) - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.tts_screen)) }, - navigationIcon = { - NavigatorBackIconButton(navigateBack = navigateBack) - } - ) - } - ) { padding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(padding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item { - Text( - text = stringResource(R.string.tts_book_id), - style = MaterialTheme.typography.bodyMedium, - ) - Text( - text = bookRemoteId, - style = MaterialTheme.typography.bodySmall, - ) - } - - if (state.isLoading && state.engines.isEmpty()) { - item { - CircularProgressIndicator() - } - } - - state.error?.let { error -> - item { - Text( - text = error, - color = MaterialTheme.colorScheme.error, - ) - } - } - - item { - Text( - text = stringResource(R.string.tts_engine), - style = MaterialTheme.typography.titleMedium, - ) - } - - items(state.engines.size) { index -> - val engine = state.engines[index] - EngineItem( - engine = engine, - selected = state.selectedEngine?.id == engine.id, - onClick = { model.selectEngine(engine) } - ) - } - - item { - Text( - text = stringResource(R.string.tts_voice), - style = MaterialTheme.typography.titleMedium, - ) - } - - items(state.voices.size) { index -> - val voice = state.voices[index] - VoiceItem( - voice = voice, - selected = state.selectedVoice?.id == voice.id, - onClick = { model.selectVoice(voice) } - ) - } - - item { - Text( - text = stringResource(R.string.tts_speed), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.tts_speed_label, state.speed), - style = MaterialTheme.typography.bodyMedium, - ) - Slider( - value = state.speed.toFloat(), - onValueChange = { model.updateSpeed(it.toDouble()) }, - valueRange = 0.5f..2.0f, - steps = 30, - modifier = Modifier.fillMaxWidth() - ) - } - - item { - Button( - onClick = { model.createJob(bookRemoteId) }, - enabled = state.selectedEngine != null && - state.selectedVoice != null && - !state.isCreatingJob && - state.job?.isRunning != true, - modifier = Modifier.fillMaxWidth() - ) { - if (state.isCreatingJob) { - CircularProgressIndicator() - } else { - Text(stringResource(R.string.tts_create_job)) - } - } - } - - state.job?.let { job -> - item { - Card(modifier = Modifier.fillMaxWidth()) { - Text( - text = stringResource(R.string.tts_status_label, job.status), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(16.dp) - ) - LinearProgressIndicator( - progress = { job.progress.toFloat().coerceIn(0f, 1f) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) - Text( - text = stringResource( - R.string.tts_progress_label, - job.completedChapters, - job.totalChapters - ), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(16.dp) - ) - } - } - } - - state.outputFilePath?.let { path -> - item { - Text( - text = stringResource(R.string.tts_downloaded_label, path), - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - } -} - -@Composable -private fun EngineItem( - engine: TtsEngine, - selected: Boolean, - onClick: () -> Unit, -) { - ListItem( - headlineContent = { Text(engine.name) }, - supportingContent = { Text(engine.id) }, - trailingContent = { - if (selected) { - Text(stringResource(R.string.tts_selected_label)) - } - }, - modifier = Modifier.fillMaxWidth(), - tonalElevation = if (selected) 4.dp else 0.dp, - shadowElevation = if (selected) 2.dp else 0.dp, - ) -} - -@Composable -private fun VoiceItem( - voice: TtsVoice, - selected: Boolean, - onClick: () -> Unit, -) { - ListItem( - headlineContent = { Text(voice.name) }, - supportingContent = { - Text( - stringResource( - R.string.tts_voice_description, - voice.language, - voice.engine - ) - ) - }, - trailingContent = { - if (selected) { - Text(stringResource(R.string.tts_selected_label)) - } - }, - modifier = Modifier.fillMaxWidth(), - tonalElevation = if (selected) 4.dp else 0.dp, - shadowElevation = if (selected) 2.dp else 0.dp, - ) -} diff --git a/app/src/main/res/drawable/audiobook_tab_filled.xml b/app/src/main/res/drawable/audiobook_tab_filled.xml deleted file mode 100644 index 7d76067b..00000000 --- a/app/src/main/res/drawable/audiobook_tab_filled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/audiobook_tab_outlined.xml b/app/src/main/res/drawable/audiobook_tab_outlined.xml deleted file mode 100644 index 70ef3334..00000000 --- a/app/src/main/res/drawable/audiobook_tab_outlined.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/book_tab_filled.xml b/app/src/main/res/drawable/book_tab_filled.xml deleted file mode 100644 index eb22138a..00000000 --- a/app/src/main/res/drawable/book_tab_filled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/book_tab_outlined.xml b/app/src/main/res/drawable/book_tab_outlined.xml deleted file mode 100644 index 34bbd309..00000000 --- a/app/src/main/res/drawable/book_tab_outlined.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml deleted file mode 100644 index fdf1b759..00000000 --- a/app/src/main/res/drawable/ic_notification.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/search_tab_filled.xml b/app/src/main/res/drawable/search_tab_filled.xml deleted file mode 100644 index 4c39a21b..00000000 --- a/app/src/main/res/drawable/search_tab_filled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/search_tab_outlined.xml b/app/src/main/res/drawable/search_tab_outlined.xml deleted file mode 100644 index 4c39a21b..00000000 --- a/app/src/main/res/drawable/search_tab_outlined.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/settings_tab_filled.xml b/app/src/main/res/drawable/settings_tab_filled.xml deleted file mode 100644 index b57b97aa..00000000 --- a/app/src/main/res/drawable/settings_tab_filled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/settings_tab_outlined.xml b/app/src/main/res/drawable/settings_tab_outlined.xml deleted file mode 100644 index 69f70a88..00000000 --- a/app/src/main/res/drawable/settings_tab_outlined.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2307e75e..52c2b28e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -6,618 +6,552 @@ - KRait - По умолчанию - - - Воспроизведение аудио - Показывает текущую аудиокнигу - Нет трека - -10с - +30с - Предыдущий - Следующий - Играть - Пауза + Book\'s Story + Default - Библиотека - История - Обзор - Настройки - О приложении + Library + History + Browse + Settings + About app - Добавить книги? - Удалить книги? - Удалить книгу? - Удалить историю чтения? - Удалить категорию? + Add books? + Delete books? + Delete book? + Delete reading history? + Remove category? - Вы можете выбрать книги, которые хотите добавить. - Название и обложку можно изменить позже. - Загрузка может занять несколько минут. + You can choose which books you want to add. + You can edit Title and Cover image of the book later. + The loading process can take up to several minutes. - Это удалит все выбранные книги (%1$s) из базы данных. - Файлы на устройстве не будут удалены. + It will delete all selected books(%1$s) from the database. + It will not delete selected books from your device. - Это удалит книгу из базы данных. - Файл на устройстве не будет удалён. + It will delete this book from the database. + It will not delete this book from your device. - Это удалит всю историю из базы данных. - После удаления порядок книг будет сброшен. + It will delete the whole history from the database. + After deleting the whole history, the order of the books will be reset. - Категория будет удалена из библиотеки, но книги не будут удалены. + This will remove this category from the Library but will not delete any books. - Нет описания. - Что-то пошло не так. - Приложение-переводчик не найдено. - Словарь не найден. - Браузер не найден. - Нет приложения для отправки. - Нет приложения для выбора папки. + No description. + Something went wrong. + No translator app found. + No dictionary app found. + No browser app found. + No app to share found. + No app to choose folder found. - Файл пуст, отсутствует или недоступен. - Проверьте файл, настройки сканирования или измените путь. + File is empty, missing, or inaccessible. + Verify the file, scan settings or change the path, then try again. - Не удалось сбросить обложку. - Закрытый исходный код - Файл не существует или недоступен - Поле не должно быть пустым. + Could not reset cover image. + Closed source + File does not exist or inaccessible + This field should not be empty. - Поиск… + Search… - Расширьте коллекцию книг, загрузив новые файлы + Expand the library\'s book collection with your downloads - Читайте любимые книги и сортируйте их по категориям + Read your favorite books and sort them between categories - Откройте последнюю книгу и продолжите чтение + Let your last book you read open the pages of a new great story of your reading journey - Нет категорий. + No categories. - %1$s выбрано - Пресет %1$s - Выбран «%1$s» + %1$s selected + Preset %1$s + Selected «%1$s» - Отмена - Добавить книгу - Переместить - Удалить - Никогда - Отменить - Копировать - Сообщить - Перевести - Словарь - Далее - Готово - Поиск в сети - Поделиться - ОК - Читать - Изменить путь - Добавить папку - Изменить - Создать категорию - Настроить сканирование + Cancel + Add a book + Move + Delete + Never + Undo + Copy + Report + Translate + Dictionary + Next + Done + Web search + Share + OK + Read + Change path + Add folder + Edit + Create category + Set up scanning - Неизвестен - Сбросить обложку - Сбросить обложку книги по умолчанию - Изменить обложку - Выбрать новую обложку из фото - Удалить обложку - Удалить текущую обложку книги - Автор + Unknown + Reset cover image + Reset cover image of the book to the default + Change cover image + Choose new cover from your photos + Delete cover image + Delete current cover image of the book + Author - По умолчанию - Общие - Отображение - Цвета - Фильтр - Сортировка - Читалка + Default + General + Display + Colors + Filter + Sort + Reader - Общие - Язык приложения - Внешний вид - Тема, цвета - Читалка - Шрифт, текст - Обзор - Сканирование, отображение - Библиотека - Категории, отображение, вкладки - Книжная полка - Сервер, ABS, TTS + General + App language + Appearance + Theme, colors + Reader + Font, text + Browse + Scan, display + Library + Categories, display, tabs + Bookshelf + Server, ABS, TTS TTS - Движок - Голос - ID книги - Скорость - Создать задачу - Обновить задачи - Задачи - Задачи TTS + Engine + Voice + Book ID + Speed + Create job + Refresh jobs + Jobs + TTS jobs TTS - Сервер книжной полки - URL Bookshelf API - Базовый URL сервиса bookshelf-api - URL Audiobookshelf - Прямой URL сервера Audiobookshelf - Токен ABS - API-токен из настроек Audiobookshelf - Сохранить - Проверить соединение - Получить ABS URL - Загрузить библиотеки - Соединение установлено - Подключено к %1$d библиотекам - Библиотеки - %1$s • %2$d элементов - Выбрано - Книги в %1$s - Удалённые библиотеки не найдены - Обновить - Повторить + Bookshelf server + Bookshelf API URL + Base URL of the bookshelf-api service + Audiobookshelf URL + Direct URL of the Audiobookshelf server + ABS token + API token from Audiobookshelf settings + Save + Check connection + Derive ABS URL + Fetch libraries + Connection successful + Connected to %1$d libraries + Libraries + %1$s • %2$d items + Selected + Books in %1$s + No remote libraries found + Refresh + Retry - Шрифт - Текст - Переводчик - Поля - Прочее - Скорость чтения - Режим чтения - Постраничный режим - Перелистывание страниц вместо вертикальной прокрутки - Система - Главы - Изображения - Прогресс - Настройки темы - Цвета - Сканирование - Отображение - Вкладки - Категории - Фильтр - Сортировка + Font + Text + Translator + Margins + Misc + Reading speed + Reading mode + System + Chapters + Images + Progress + Theme preferences + Colors + Scan + Display + Tabs + Categories + Filter + Sort - Эти папки используются для сканирования файлов в Обзоре. - Удаление папки не удалит сами файлы или книги из Библиотеки. - Чтобы отозвать доступ, нажмите соответствующую кнопку. + These folders are used to scan and retrieve files in the Browse. + Removing a folder here will not delete actual files or books from the Library. + To revoke access, click the corresponding button. - Изначально книги добавляются в категорию по умолчанию, - но могут принадлежать нескольким категориям, созданным здесь. - Удаление категории не удалит книги из Библиотеки. - Удерживайте и перетаскивайте иконку категории для изменения порядка. + Initially, books are added to the default category, + but can belong to several custom categories created here. + Removing a category here will not delete books from the Library. + Hold and move the category icon to rearrange the categories. - Сортировку можно изменить в Библиотеке. + Sorting can be changed in the Library. - Язык приложения - Тёмная тема - Полностью тёмная - Абсолютно тёмная - Сделать цвет фона полностью чёрным - Контраст темы - Тема приложения - Двойное нажатие для выхода - Нажмите «Назад» дважды для выхода - Подписи навигации - Показывать подписи под иконками навигации + App language + Dark theme + Pure dark + Absolute dark + Change background color to be completely black + Theme contrast + App theme + Double back press to exit + Press back twice to exit the app + Show navigation labels + Show navigation bar labels under icons - Гарнитура шрифта - Толщина шрифта - Начертание шрифта - Размер шрифта - Межстрочный интервал - Интервал между абзацами - Отступ абзаца - Цветовой пресет - Цвет фона - Цвет шрифта - Боковые поля - Вертикальные поля - Перевод по двойному клику - Двойной клик по абзацу для перевода - Быстрая смена пресета - Свайп верхней панели для смены пресета - Выравнивание текста - Выравнивание заголовка главы - Межбуквенный интервал - Отступ выреза - Отступ для области выреза экрана - Полноэкранный режим - Скрыть системные панели и поля - Не выключать экран - Скрыть панели при быстрой прокрутке - Тренажёр скорости + Font family + Font thickness + Font style + Font size + Line height + Paragraph spacing + Paragraph indentation + Color preset + Background color + Font color + Side margins + Vertical margins + Double click translation + Double click on paragraph to translate it + Fast color preset change + Swipe top bar left or right to change color preset + Text alignment + Chapter title alignment + Letter spacing + Cutout margin + Apply margin to the cutout area + Fullscreen + Hide system bars and margins + Keep screen on + Hide bars on fast scroll + Perception expander - Увеличьте скорость чтения, фокусируясь на центре + Increase your reading speed by focusing on center - Боковой отступ линии - Толщина линии - Горизонтальный ограничитель + Line side margin + Line thickness + Horizontal limiter - Показывать линейку и затемнять нефокусируемую часть экрана + Show horizontal ruler and dim unfocused part of the screen - Высота - Вертикальное смещение - Горизонтальная линейка - Толщина линейки - Затемнение - Ориентация экрана - Своя яркость - Яркость - Горизонтальный жест - Доля прокрутки - Чувствительность - Анимация видимости + Height + Vertical offset + Horizontal ruler + Ruler thickness + Dimming + Screen orientation + Custom brightness + Brightness + Horizontal gesture + Scroll fraction + Sensitivity + Visibility animation - Анимация видимости при горизонтальном жесте + Enable visibility animation when using horizontal gesture - Анимация подтягивания + Pull animation - Анимация подтягивания при горизонтальном жесте + Enable pull animation when using horizontal gesture - Отключить прокрутку - Полностью отключить прокрутку читалки - Отступ нижней панели - Подсветка слов - Подсвечивать начало каждого слова - Толщина подсветки - Изображения - Показывать изображения - Подписи - Показывать подписи под изображениями - Скругление углов - Выравнивание - Размер - Цветовые эффекты - Индикатор прогресса - Показывать индикатор прогресса внизу - Отступы - Выравнивание - Размер шрифта - Отсчёт прогресса + Disable scrolling + Completely disable reader scrolling + Bottom bar margin + Highlight words + Highlight the beginning of each word + Highlight thickness + Images + Display images + Captions + Display captions under images + Corners roundness + Alignment + Size + Color effects + Progress bar + Display progress bar at the bottom + Margins + Alignment + Font size + Progress count - Показывать кнопку «Читать» - Показывать прогресс - Позиция заголовка - Показывать количество книг - Показывать вкладки категорий - Всегда показывать вкладку по умолчанию - Сортировка по категориям + Show «Read» button + Show progress + Title position + Show book count + Show category tabs + Always show default tab + Per-category sort - Красный - Зелёный - Синий - Название пресета… + Red + Green + Blue + Name a preset… - Выключено - Включено - Как в системе + Disabled + Enabled + System - Выключено - Включено - В режиме экономии + Disabled + Enabled + Saver - Стандартный - Средний - Высокий + Standard + Medium + High - Курсив - Обычный + Italic + Normal - Скрыт - Снизу - Внутри + Hidden + Below + Inside - Название + Title - Режим отображения - Список - Сетка + Display mode + List + Grid - По алфавиту - Формат файла - Последнее изменение - Размер файла - Последнее чтение - Прогресс - Автор + Alphabetically + File format + Last modified + File size + Last read + Progress + Author - Размер сетки - Авто - в ряд + Grid size + Auto + per row - По началу - По ширине - По центру - По концу + Start + Justify + Center + End - Свободная - Портретная - Альбомная - Заблокированная портретная - Заблокированная альбомная + Free + Portrait + Landscape + Locked portrait + Locked landscape - Выключено - Включено - Включено (инверсия) + Off + On + On (Inverse) - уровень + level - Выключено - Оттенки серого - Цвет шрифта - Цвет фона + Off + Grayscale + Font color + Background color - Тонкий - Очень светлый - Светлый - Обычный - Средний + Thin + Extra light + Light + Normal + Medium - Проценты - Количество + Percentage + Quantity - Меркурий - Нептун - Земля - Ио - Энцелад - Марс - Каллисто - Юпитер - Церера - Сатурн - Ганимед - Эрида - Плутон - Венера - Макемаке - Уран - Япет + Mercury + Neptune + Earth + Io + Enceladus + Mars + Callisto + Jupiter + Ceres + Saturn + Ganymede + Eris + Pluto + Venus + Makemake + Uranus + Iapetus - Нажмите «Назад» ещё раз для выхода - Все выбранные книги добавлены. - Все выбранные книги перемещены. - Все выбранные книги удалены. - Обложка изменена. - Обложка удалена. - Книга удалена. - Книга перемещена. - Скопировано - История удалена. - Обложка сброшена. - Название изменено. - Автор изменён. - Описание изменено. - Путь к файлу изменён. + Press back again to exit + All selected books were successfully added. + All selected books were successfully moved. + All selected books were successfully deleted. + Cover Image was successfully changed. + Cover Image was successfully deleted. + This book was successfully deleted. + This book was successfully moved. + Copied + History was successfully deleted. + Cover image was successfully reset. + Title was successfully changed. + Author was successfully changed. + Description was successfully changed. + File path was successfully changed. - Элемент истории удалён. + History element was successfully deleted. - Начать - Продолжить с %1$s - Детали файла - Путь - Последнее открытие - Размер - Неизвестно + Start + Continue from %1$s + File details + Path + Last opened + Size + Unknown - Нет глав - Главы + No chapters + Chapters - Версия приложения - Сообщить об ошибке - Открытые лицензии - Благодарности - Помочь с переводом - Поддержать разработку - Участники - Слава Україні! + App version + Report a bug + Open source licenses + Credits + Help translate + Support development + Contributors + Slava Ukraini! Reddit GitHub GitLab Codeberg - Тризуб + Tryzub - Дизайн - Иконки - Управление обновлениями - Шрифты - Перевод - Вклад - Идеи + Design + Icons + New updates managing + Fonts + Translation + Contribution + Ideas - Добро пожаловать в %1$s! + Welcome to %1$s! - В мир, где слова чаруют, - учат и ведут в невероятные приключения! - Давайте всё настроим! + To a world where words charm, + teach and lead on incredible adventures! + Let\'s set some things up! - Языковые настройки - Настройки темы - Настройки сканирования - Всё готово! - Настройка завершена. + Language preferences + Theme preferences + Scan preferences + Everything is done! + Setup completed. - Сегодня - Вчера + Today + Yesterday - Ой! Приложение завершилось с ошибкой. - Пожалуйста, сообщите об ошибке удобным способом. + Oops! The app crashed. + Please report this crash message in the most convenient way for you. - Продолжить чтение - Назад - Обложка - Настройки читалки - Файл - Порядок сортировки - Чекбокс - Обложка не найдена - Ошибка - Поиск - Очистить выбранные элементы - Добавить файлы - Выбрать все файлы - Выбрать все книги - Выйти из поиска - Удалить всю историю - Переместить выбранные книги - Удалить выбранные книги - Отменить - Выбрано - Иконка приложения - Удалить элемент истории - Ваша библиотека - История чтения - Добавить книги - Книги - Удалённые книги - Аудиокниги - Удалённые аудиокниги - Поиск - Поиск в сети - - Книжная полка - Удалённая книжная полка - Только оффлайн - Ожидание - Загрузка - В кэше - Ошибка - Скачать - Удалить - Слушать - Главы - Не удалось синхронизировать прогресс чтения - Не удалось синхронизировать прогресс воспроизведения - Стрелка - Сбросить начальную настройку - Открыть в браузере - Удалить цветовой пресет - Создать цветовой пресет - Перемешать цвета - Перетащить - Фильтр - Контрольная точка назад - Контрольная точка вперёд - Главы - Ещё - Закрепить - Свернуть - Развернуть - Изменить - Раскрыть - Свернуть - Удалить - Заметка - - - Статус: %1$s - Прогресс: %1$d / %2$d глав - Сохранено в: %1$s - Выбрано - Скорость: %1$.2fx - Создать TTS-задачу - Книга: %1$s - %1$s • %2$s - Не удалось создать TTS-задачу - Ошибка загрузки TTS - - - Скорочтение - Режим скорочтения - Играть - Пауза - Назад - −10 слов - +10 слов - %1$d слов/мин - %1$d / %2$d слов - ~%1$d мин осталось - Скорочтение - Запустить режим RSVP - - - Подготовка слов… - Не удалось извлечь текст для скорочтения - В книге нет слов - Скорочтение - Скорость (слов/мин) - Размер шрифта - Пауза между абзацами - Доп. пауза в конце каждого абзаца - Пауза между главами - Длинная пауза после заголовка главы - Замедление длинных слов - +30% времени на слова длиннее 9 букв - Показывать направляющие фокуса - ▲▼ метки над и под точкой фиксации + Continue reading + Go back + Cover image + Reader settings + File + Sort order + Checkbox + Cover image not found + Error + Search + Clear selected items + Add files + Select all files + Select all books + Exit search + Delete whole history + Move selected books + Delete selected books + Revert + Selected + App icon + Delete history element + Your library + Reading history + Add books + Bookshelf + Remote bookshelf + Offline only + Pending + Downloading + Cached + Failed + Download + Delete + Arrow + Reset start guide + Open in web + Delete color preset + Create new color preset + Shuffle colors + Drag + Filter + Checkpoint back + Checkpoint forward + Chapters + More + Pin + Show less + Show more + Edit + Expand + Collapse + Remove + Note \ 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 deleted file mode 100644 index b70e4a65..00000000 --- a/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt +++ /dev/null @@ -1,422 +0,0 @@ -package org.dueattendant149.bookshelf.data.remote.bookshelfapi - -import kotlinx.coroutines.runBlocking -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -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.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 -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.TtsCreateRequest -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesResponse -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.YandexRequest -import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import retrofit2.Response -import retrofit2.Retrofit -import retrofit2.converter.kotlinx.serialization.asConverterFactory -import java.util.concurrent.TimeUnit - -class BookshelfApiServiceTest { - - private lateinit var server: MockWebServer - private lateinit var service: BookshelfApiService - - @Before - fun setUp() { - server = MockWebServer() - - val json = Json { - ignoreUnknownKeys = true - explicitNulls = false - } - - val okHttpClient = OkHttpClient.Builder() - .connectTimeout(5, TimeUnit.SECONDS) - .readTimeout(5, TimeUnit.SECONDS) - .build() - - val retrofit = Retrofit.Builder() - .baseUrl(server.url("/")) - .client(okHttpClient) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - - service = retrofit.create(BookshelfApiService::class.java) - } - - @After - fun tearDown() { - server.shutdown() - } - - private fun jsonResponse(body: String) = MockResponse() - .setHeader("Content-Type", "application/json") - .setBody(body) - - @Test - fun `all endpoints can be created without converter errors`() { - assertNotNull(service) - } - - @Test - fun `health returns success`() = runBlocking { - server.enqueue(jsonResponse("""{"status":"ok"}""")) - val response: Response = service.health() - assertTrue(response.isSuccessful) - } - - @Test - fun `getLibraries deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - [ - {"id":"lib-1","name":"Audiobooks","media_type":"book","item_count":42}, - {"id":"lib-2","name":"Ebooks","media_type":"book","item_count":15} - ] - """.trimIndent())) - val response: Response> = service.getLibraries() - assertTrue(response.isSuccessful) - val libraries: List = response.body()!! - assertEquals(2, libraries.size) - assertEquals("lib-1", libraries[0].id) - assertEquals("Audiobooks", libraries[0].name) - assertEquals("book", libraries[0].mediaType) - 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(""" - [ - { - "id":"item-1", - "title":"War and Peace", - "author":"Leo Tolstoy", - "media_type":"book", - "library_id":"lib-1", - "cover_url":"https://example.com/cover.jpg", - "duration":0.0, - "size":2048000 - } - ] - """.trimIndent())) - val response: Response> = service.searchLibrary("lib-1", "war") - assertTrue(response.isSuccessful) - val books: List = response.body()!! - assertEquals(1, books.size) - assertEquals("item-1", books[0].id) - assertEquals("War and Peace", books[0].title) - assertEquals("Leo Tolstoy", books[0].author) - assertEquals("book", books[0].mediaType) - assertEquals("lib-1", books[0].libraryId) - assertEquals("https://example.com/cover.jpg", books[0].coverUrl) - assertEquals(2048000L, books[0].size) - } - - @Test - fun `getBook returns raw response body`() = runBlocking { - server.enqueue(jsonResponse("""{"id":"item-1","media":{"metadata":{"title":"Test"}}}""")) - val response: Response = service.getBook("item-1") - assertTrue(response.isSuccessful) - val body = response.body() - assertNotNull(body) - val raw = body!!.string() - assertTrue(raw.contains("item-1")) - } - - @Test - fun `updateReadingProgress succeeds`() = runBlocking { - server.enqueue(jsonResponse("""{"itemId":"item-1","libraryId":"lib-1","scrollIndex":5,"scrollOffset":100,"progress":0.5,"last_chapter":"Ch1","updatedAt":1718611200000}""")) - val request = ProgressUpdateRequest( - itemId = "item-1", - libraryId = "lib-1", - scrollIndex = 5, - scrollOffset = 100, - progress = 0.5f, - lastChapter = "Ch1", - updatedAt = 1718611200000L, - ) - val response: Response = service.updateReadingProgress("item-1", request) - assertTrue(response.isSuccessful) - } - - @Test - fun `updatePlaybackProgress succeeds`() = runBlocking { - server.enqueue(jsonResponse("""{"itemId":"item-2","libraryId":"lib-2","current_file":"track-1.m4b","current_position":12345,"duration":360000,"updatedAt":1718611200001}""")) - val request = PlaybackProgressUpdateRequest( - itemId = "item-2", - libraryId = "lib-2", - currentFile = "track-1.m4b", - currentPosition = 12345L, - duration = 360000L, - updatedAt = 1718611200001L, - ) - val response: Response = service.updatePlaybackProgress("item-2", request) - assertTrue(response.isSuccessful) - } - - @Test - fun `getTtsEngines deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - { - "engines": [ - { - "id":"silero", - "name":"Silero", - "capabilities": { - "supports_streaming":false, - "supports_cloning":false, - "max_text_length":5000, - "needs_network":false - } - } - ] - } - """.trimIndent())) - val response: Response = service.getTtsEngines() - assertTrue(response.isSuccessful) - val engines: TtsEnginesResponse = response.body()!! - assertEquals(1, engines.engines.size) - assertEquals("silero", engines.engines[0].id) - } - - @Test - fun `getTtsVoices deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - { - "voices": [ - { - "id":"baya", - "name":"Baya", - "language":"ru-RU", - "engine":"silero", - "gender":"female", - "quality":"high", - "requires_reference":false - } - ] - } - """.trimIndent())) - val response: Response = service.getTtsVoices("silero") - assertTrue(response.isSuccessful) - val voices: TtsVoicesResponse = response.body()!! - assertEquals(1, voices.voices.size) - assertEquals("baya", voices.voices[0].id) - assertEquals("ru-RU", voices.voices[0].language) - } - - @Test - fun `createTtsJob deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - { - "job_id":"job-1", - "book_id":"item-1", - "title":"", - "author":"", - "engine":"silero", - "voice_id":"baya", - "speed":1.0, - "status":"queued", - "progress":0.0, - "current_chapter":"", - "total_chapters":0, - "completed_chapters":0, - "output_path":"", - "error":"", - "created_at":1718611200.0, - "started_at":0.0, - "completed_at":0.0 - } - """.trimIndent())) - val request = TtsCreateRequest(bookId = "item-1", engine = "silero", voiceId = "baya", speed = 1.0) - val response: Response = service.createTtsJob(request) - assertTrue(response.isSuccessful) - val job: TtsJobResponse = response.body()!! - assertEquals("job-1", job.jobId) - assertEquals("queued", job.status) - } - - @Test - fun `listTtsJobs deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - { - "jobs": [ - { - "job_id":"job-1", - "book_id":"item-1", - "title":"Test Book", - "author":"Author", - "engine":"silero", - "voice_id":"baya", - "speed":1.0, - "status":"completed", - "progress":1.0, - "current_chapter":"Chapter 10", - "total_chapters":10, - "completed_chapters":10, - "output_path":"/audiobooks/Test.m4b", - "error":"", - "created_at":1718611200.0, - "started_at":1718611300.0, - "completed_at":1718611900.0 - } - ] - } - """.trimIndent())) - val response: Response = service.listTtsJobs() - assertTrue(response.isSuccessful) - val jobs: TtsJobsResponse = response.body()!! - assertEquals(1, jobs.jobs.size) - assertEquals("completed", jobs.jobs[0].status) - } - - @Test - fun `search deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - { - "results": [ - { - "title":"Test Book", - "author":"Author", - "size":"10 MB", - "size_human":"10 MB", - "seeders":5, - "media_type":"book", - "magnet_url":"magnet:?xt=urn:btih:abc", - "download_url":"https://example.com/dl", - "cover_url":"https://example.com/cover.jpg", - "info_hash":"abc123", - "source":"flibusta", - "download_protocol":"torrent", - "language":"ru" - } - ], - "total":1, - "search_time_ms":150 - } - """.trimIndent())) - val request = SearchRequest(query = "test") - val response: Response = service.search(request) - assertTrue(response.isSuccessful) - val search: SearchResponse = response.body()!! - assertEquals(1, search.total) - assertEquals("Test Book", search.results[0].title) - } - - @Test - fun `startDownload deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse("""{"success":true,"error":null,"download_id":"dl-1"}""")) - val request = DownloadRequest(url = "magnet:?xt=urn:btih:abc", title = "Test") - val response: Response = service.startDownload(request) - assertTrue(response.isSuccessful) - val download: DownloadResponse = response.body()!! - assertTrue(download.success) - assertEquals("dl-1", download.downloadId) - } - - @Test - fun `listDownloads deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse(""" - { - "downloads": [ - { - "id":"dl-1", - "title":"Test Book", - "status":"downloading", - "progress":0.5, - "error":null, - "created_at":1718611200.0 - } - ] - } - """.trimIndent())) - val response: Response = service.listDownloads() - assertTrue(response.isSuccessful) - val downloads: DownloadsListResponse = response.body()!! - assertEquals(1, downloads.downloads.size) - assertEquals("downloading", downloads.downloads[0].status) - } - - @Test - fun `downloadPodcast deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse("""{"url":"https://youtube.com/watch?v=abc","title":"Podcast","tracks":3,"files":["f1.m4a","f2.m4a"],"abs_scan_triggered":true}""")) - val request = PodcastRequest(url = "https://youtube.com/watch?v=abc") - val response: Response = service.downloadPodcast(request) - assertTrue(response.isSuccessful) - val podcast: PodcastResponse = response.body()!! - assertEquals("Podcast", podcast.title) - assertTrue(podcast.absScanTriggered) - } - - @Test - fun `downloadYandex deserializes correctly`() = runBlocking { - server.enqueue(jsonResponse("""{"bookid":"123","files":["book.epub"],"abs_scan_triggered":false}""")) - val request = YandexRequest(bookid = "123") - val response: Response = service.downloadYandex(request) - assertTrue(response.isSuccessful) - val yandex: YandexResponse = response.body()!! - assertEquals("123", yandex.bookid) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index bf2e129f..f076ace1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,6 @@ plugins { id("com.android.application") version "8.11.1" apply false id("org.jetbrains.kotlin.android") version "2.2.0" apply false id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false - id("org.jetbrains.kotlin.plugin.serialization") version "2.2.0" apply false id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false id("com.google.dagger.hilt.android") version "2.57.2" apply false id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false