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