diff --git a/AGENTS.md b/AGENTS.md
index 37a323a8..1361dcff 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,7 +13,7 @@
- **Аудио:** ExoPlayer (Media3) + MediaSession
- **Фоновые задачи:** WorkManager
- **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark)
-- **minSdk:** 31
+- **minSdk:** 26 (план — поднять до 31 позже)
- **compileSdk/targetSdk:** 36
## Goal
@@ -66,46 +66,14 @@
- Phase 1: fork + package rename completed.
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
-- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17→18) completed.
+- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed.
- Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
-- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) completed.
-- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) completed.
-- Phase 6: feature integration on `feature/ui-polish` completed:
- - RemoteLibrary cache UI (offline-only filter, download/delete, progress indicator).
- - Remote ebook opening in the existing reader.
- - Audiobook player with ExoPlayer + MediaSession.
- - TTS jobs screen via bookshelf-api.
- - Reading and playback progress sync with periodic WorkManager worker.
-- Current: `feature/ui-polish` builds and produces `app-debug.apk` (~58 MB). Debug APK uploaded as `KRait-debug-15.apk` to ownCloud + caddy download site.
-- Polished: PlayerContent and TtsContent labels moved to `strings.xml`; TTS WorkManager observer leak fixed (`TtsModel` removes observer in `onCleared`).
-- Raised `minSdk` from 26 to 31.
-- Phase 7 (nav redesign): `MainActivity` tabs = Книги | Аудиокниги | Поиск | Настройки.
- - `BooksModel` / `AudiobooksModel` extend `RemoteLibraryViewModel` with `mediaTypeFilter`.
- - Library filter uses name fallback ("Books" / "Audiobooks") because ABS returns `mediaType: "book"` for both.
- - `SearchModel` + `SearchContent` for global Librarr search (`/api/v1/search`).
- - `ServerStatusMonitor` (singleton, pings `/health` every 30 s) → auto offline-only when down.
- - `SettingsScreen` is a root tab — back button hidden when stack size ≤ 1.
-- Phase 8 (RSVP speed reading): core tokenizer + engine + UI.
- - `domain/model/rsvp/` — `RsvpToken`, `OrpTable`, `RsvpTokenizer`, `RsvpEngine`.
- - `presentation/rsvp/` — `RsvpScreen`, `RsvpModel`, `RsvpState/Event/Effect`.
- - `ui/rsvp/RsvpContent.kt` — word stage with prefix/pivot/suffix, focus guide ▲▼, controls bar.
- - `ReaderTopBar` got a ⚡ button → `RsvpScreen(bookId)`.
- - Persists reading progress on close via `UpdateBookUseCase`.
- - Settings: `rsvpWpm` (100–900), `rsvpFontSize` (24–96 sp), `rsvpPauseOnParagraph`, `rsvpPauseOnChapter`, `rsvpPauseOnLongWords`, `rsvpShowFocusGuide`.
-- Phase 9 (reader pagination + polish):
- - Kindle-style paginated reading mode with `HorizontalPager`.
- - Volume keys (Up/Down) mapped to page navigation via `VolumeKeyWindowCallback`.
- - Pagination measurement/render synchronization: exact page geometry, real inter-item spacing, image sizing, chapter title extras.
- - Algorithm improvements: `TextLayoutResult.getLineForVerticalPosition()` split point, better widow/orphan handling.
-- Phase 10 (bugfixes):
- - Fixed search crash caused by duplicate LazyColumn keys when search results have empty `guid`.
- - Fixed `BookCard` intrinsic-measurement crash risk by switching cover to fixed-height vertical variant.
-- Phase 11 (audio player hardening):
- - ExoPlayer streaming cache via `SimpleCache` + `CacheDataSource` (512 MB external cache).
- - Unified `AuthorizationInterceptor` for player, backed by `PlaybackAuthProvider` refreshed from `ServerSettings`.
-- Current APK: `KRait-debug-27.apk` (1.9.11) on `books.dueattendant149.org/download`.
-- Next: final APK rebuild after pushing to Forgejo.
+- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged.
+- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged.
+- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model.
+- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`.
+- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync.
## WARNs
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 87ea6277..5d543541 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -2,7 +2,6 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
- id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
id("kotlin-parcelize")
@@ -17,10 +16,10 @@ android {
// Default configuration
defaultConfig {
applicationId = "org.dueattendant149.bookshelf"
- minSdk = 31
+ minSdk = 26
targetSdk = 36
- versionCode = 27
- versionName = "1.9.11"
+ versionCode = 14
+ versionName = "1.8.0"
vectorDrawables {
useSupportLibrary = true
@@ -180,14 +179,4 @@ dependencies {
// Background work (cache, TTS sync)
implementation("androidx.work:work-runtime-ktx:2.10.0")
- implementation("androidx.hilt:hilt-work:1.2.0")
-
- // Test
- testImplementation("junit:junit:4.13.2")
- testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
- testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
- testImplementation("com.squareup.retrofit2:retrofit:2.11.0")
- testImplementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
- testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
- testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
}
\ No newline at end of file
diff --git a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json
deleted file mode 100644
index 42583404..00000000
--- a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/18.json
+++ /dev/null
@@ -1,500 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 18,
- "identityHash": "989c23bd1fdbd69a7d65b94ae6cf381b",
- "entities": [
- {
- "tableName": "BookEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "author",
- "columnName": "author",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "description",
- "columnName": "description",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "filePath",
- "columnName": "filePath",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "scrollIndex",
- "columnName": "scrollIndex",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "scrollOffset",
- "columnName": "scrollOffset",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "progress",
- "columnName": "progress",
- "affinity": "REAL",
- "notNull": true
- },
- {
- "fieldPath": "image",
- "columnName": "image",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "categories",
- "columnName": "categories",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'[]'"
- },
- {
- "fieldPath": "remoteId",
- "columnName": "remoteId",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "libraryId",
- "columnName": "libraryId",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "mediaType",
- "columnName": "mediaType",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "hasAudio",
- "columnName": "hasAudio",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "hasEbook",
- "columnName": "hasEbook",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "audioDuration",
- "columnName": "audioDuration",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "audioCurrentFile",
- "columnName": "audioCurrentFile",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "audioCurrentPosition",
- "columnName": "audioCurrentPosition",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "coverUrl",
- "columnName": "coverUrl",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "lastSyncedAt",
- "columnName": "lastSyncedAt",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "HistoryEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "time",
- "columnName": "time",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "ColorPresetEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER"
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "backgroundColor",
- "columnName": "backgroundColor",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fontColor",
- "columnName": "fontColor",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "isSelected",
- "columnName": "isSelected",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "CategoryEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "sortOrder",
- "columnName": "sortOrder",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'LAST_READ'"
- },
- {
- "fieldPath": "sortOrderDescending",
- "columnName": "sortOrderDescending",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "1"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "audio_file",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fileId",
- "columnName": "fileId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "remotePath",
- "columnName": "remotePath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "localPath",
- "columnName": "localPath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "duration",
- "columnName": "duration",
- "affinity": "REAL",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_audio_file_bookId",
- "unique": false,
- "columnNames": [
- "bookId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "BookEntity",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "bookId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "ebook_file",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fileId",
- "columnName": "fileId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "format",
- "columnName": "format",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "remotePath",
- "columnName": "remotePath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "localPath",
- "columnName": "localPath",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_ebook_file_bookId",
- "unique": false,
- "columnNames": [
- "bookId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "BookEntity",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "bookId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "cached_file",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "remoteUrl",
- "columnName": "remoteUrl",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "localPath",
- "columnName": "localPath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "status",
- "columnName": "status",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "progress",
- "columnName": "progress",
- "affinity": "REAL",
- "notNull": true
- },
- {
- "fieldPath": "createdAt",
- "columnName": "createdAt",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_cached_file_bookId",
- "unique": false,
- "columnNames": [
- "bookId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
- },
- {
- "name": "index_cached_file_type",
- "unique": false,
- "columnNames": [
- "type"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
- }
- ]
- }
- ],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '989c23bd1fdbd69a7d65b94ae6cf381b')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json b/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json
deleted file mode 100644
index fe24b49c..00000000
--- a/app/schemas/org.dueattendant149.bookshelf.data.local.room.BookDatabase/19.json
+++ /dev/null
@@ -1,507 +0,0 @@
-{
- "formatVersion": 1,
- "database": {
- "version": 19,
- "identityHash": "7a70be4a2d1b3fd8f89dda543d18a6b4",
- "entities": [
- {
- "tableName": "BookEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `itemType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "author",
- "columnName": "author",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "description",
- "columnName": "description",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "filePath",
- "columnName": "filePath",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "scrollIndex",
- "columnName": "scrollIndex",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "scrollOffset",
- "columnName": "scrollOffset",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "progress",
- "columnName": "progress",
- "affinity": "REAL",
- "notNull": true
- },
- {
- "fieldPath": "image",
- "columnName": "image",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "categories",
- "columnName": "categories",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'[]'"
- },
- {
- "fieldPath": "remoteId",
- "columnName": "remoteId",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "libraryId",
- "columnName": "libraryId",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "mediaType",
- "columnName": "mediaType",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "itemType",
- "columnName": "itemType",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "hasAudio",
- "columnName": "hasAudio",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "hasEbook",
- "columnName": "hasEbook",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "audioDuration",
- "columnName": "audioDuration",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "audioCurrentFile",
- "columnName": "audioCurrentFile",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "audioCurrentPosition",
- "columnName": "audioCurrentPosition",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- },
- {
- "fieldPath": "coverUrl",
- "columnName": "coverUrl",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "''"
- },
- {
- "fieldPath": "lastSyncedAt",
- "columnName": "lastSyncedAt",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "0"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "HistoryEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "time",
- "columnName": "time",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "ColorPresetEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER"
- },
- {
- "fieldPath": "name",
- "columnName": "name",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "backgroundColor",
- "columnName": "backgroundColor",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fontColor",
- "columnName": "fontColor",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "isSelected",
- "columnName": "isSelected",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "CategoryEntity",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "title",
- "columnName": "title",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "sortOrder",
- "columnName": "sortOrder",
- "affinity": "TEXT",
- "notNull": true,
- "defaultValue": "'LAST_READ'"
- },
- {
- "fieldPath": "sortOrderDescending",
- "columnName": "sortOrderDescending",
- "affinity": "INTEGER",
- "notNull": true,
- "defaultValue": "1"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- }
- },
- {
- "tableName": "audio_file",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fileId",
- "columnName": "fileId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "remotePath",
- "columnName": "remotePath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "localPath",
- "columnName": "localPath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "duration",
- "columnName": "duration",
- "affinity": "REAL",
- "notNull": true
- },
- {
- "fieldPath": "order",
- "columnName": "order",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_audio_file_bookId",
- "unique": false,
- "columnNames": [
- "bookId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "BookEntity",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "bookId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "ebook_file",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "fileId",
- "columnName": "fileId",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "format",
- "columnName": "format",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "remotePath",
- "columnName": "remotePath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "localPath",
- "columnName": "localPath",
- "affinity": "TEXT"
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_ebook_file_bookId",
- "unique": false,
- "columnNames": [
- "bookId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
- }
- ],
- "foreignKeys": [
- {
- "table": "BookEntity",
- "onDelete": "CASCADE",
- "onUpdate": "NO ACTION",
- "columns": [
- "bookId"
- ],
- "referencedColumns": [
- "id"
- ]
- }
- ]
- },
- {
- "tableName": "cached_file",
- "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
- "fields": [
- {
- "fieldPath": "id",
- "columnName": "id",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "bookId",
- "columnName": "bookId",
- "affinity": "INTEGER",
- "notNull": true
- },
- {
- "fieldPath": "type",
- "columnName": "type",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "remoteUrl",
- "columnName": "remoteUrl",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "localPath",
- "columnName": "localPath",
- "affinity": "TEXT"
- },
- {
- "fieldPath": "status",
- "columnName": "status",
- "affinity": "TEXT",
- "notNull": true
- },
- {
- "fieldPath": "progress",
- "columnName": "progress",
- "affinity": "REAL",
- "notNull": true
- },
- {
- "fieldPath": "createdAt",
- "columnName": "createdAt",
- "affinity": "INTEGER",
- "notNull": true
- }
- ],
- "primaryKey": {
- "autoGenerate": true,
- "columnNames": [
- "id"
- ]
- },
- "indices": [
- {
- "name": "index_cached_file_bookId",
- "unique": false,
- "columnNames": [
- "bookId"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
- },
- {
- "name": "index_cached_file_type",
- "unique": false,
- "columnNames": [
- "type"
- ],
- "orders": [],
- "createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
- }
- ]
- }
- ],
- "setupQueries": [
- "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
- "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a70be4a2d1b3fd8f89dda543d18a6b4')"
- ]
- }
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1b47da2d..00225368 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,12 +22,6 @@
tools:node="remove"
tools:ignore="ScopedStorage" />
-
-
-
-
-
-
-
-
-
-
-
-
-
// Additional Migrations
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt
index d3415c1f..f29b779f 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt
@@ -28,7 +28,6 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParser
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
import org.dueattendant149.bookshelf.data.parser.text.TextParser
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
-import org.dueattendant149.bookshelf.data.repository.AudiobookshelfRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
@@ -36,10 +35,7 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
-import org.dueattendant149.bookshelf.data.repository.ProgressRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
-import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl
-import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.repository.BookRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
@@ -47,9 +43,7 @@ import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
-import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Singleton
@Module
@@ -109,24 +103,6 @@ abstract class RepositoryModule {
cacheRepositoryImpl: CacheRepositoryImpl
): CacheRepository
- @Binds
- @Singleton
- abstract fun bindAudiobookshelfRepository(
- audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl
- ): AudiobookshelfRepository
-
- @Binds
- @Singleton
- abstract fun bindRemoteTtsRepository(
- remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl
- ): RemoteTtsRepository
-
- @Binds
- @Singleton
- abstract fun bindProgressRepository(
- progressRepositoryImpl: ProgressRepositoryImpl
- ): ProgressRepository
-
@Binds
@Singleton
abstract fun bindBookMapper(
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt
index da80b059..be6056a9 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/dto/BookEntity.kt
@@ -32,8 +32,6 @@ data class BookEntity(
val libraryId: String = "",
@ColumnInfo(defaultValue = "")
val mediaType: String = "",
- @ColumnInfo(defaultValue = "")
- val itemType: String = "",
@ColumnInfo(defaultValue = "0")
val hasAudio: Boolean = false,
@ColumnInfo(defaultValue = "0")
@@ -41,10 +39,6 @@ data class BookEntity(
@ColumnInfo(defaultValue = "0")
val audioDuration: Long = 0L,
@ColumnInfo(defaultValue = "")
- val audioCurrentFile: String = "",
- @ColumnInfo(defaultValue = "0")
- val audioCurrentPosition: Long = 0L,
- @ColumnInfo(defaultValue = "")
val coverUrl: String = "",
@ColumnInfo(defaultValue = "0")
val lastSyncedAt: Long = 0L,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt
index 4855ebb8..55e71b78 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDao.kt
@@ -38,9 +38,6 @@ interface BookDao {
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
suspend fun findBooksByLibraryId(libraryId: String): List
- @Query("SELECT * FROM bookentity WHERE remoteId != ''")
- suspend fun findBooksWithRemoteId(): List
-
@Delete
suspend fun deleteBook(book: BookEntity): Int
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt
index 1939a28b..1495842b 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/BookDatabase.kt
@@ -34,7 +34,7 @@ import java.io.File
EbookFileEntity::class,
CachedFileEntity::class,
],
- version = 19,
+ version = 17,
autoMigrations = [
AutoMigration(1, 2),
AutoMigration(2, 3),
@@ -203,25 +203,6 @@ object DatabaseHelper {
@DeleteTable("CategorySortEntity")
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
- val MANUAL_MIGRATION_18_19 = object : Migration(18, 19) {
- override fun migrate(database: SupportSQLiteDatabase) {
- database.execSQL(
- "ALTER TABLE BookEntity ADD COLUMN itemType TEXT NOT NULL DEFAULT ''"
- )
- }
- }
-
- val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) {
- override fun migrate(database: SupportSQLiteDatabase) {
- database.execSQL(
- "ALTER TABLE BookEntity ADD COLUMN audioCurrentFile TEXT NOT NULL DEFAULT ''"
- )
- database.execSQL(
- "ALTER TABLE BookEntity ADD COLUMN audioCurrentPosition INTEGER NOT NULL DEFAULT 0"
- )
- }
- }
-
val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add remote / bookshelf columns to BookEntity
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt
index 771c4ba7..395bc2ba 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/mapper/book/BookMapperImpl.kt
@@ -29,12 +29,9 @@ class BookMapperImpl @Inject constructor() : BookMapper {
remoteId = book.remoteId,
libraryId = book.libraryId,
mediaType = book.mediaType,
- itemType = book.itemType,
hasAudio = book.hasAudio,
hasEbook = book.hasEbook,
audioDuration = book.audioDuration,
- audioCurrentFile = book.audioCurrentFile,
- audioCurrentPosition = book.audioCurrentPosition,
coverUrl = book.coverUrl,
lastSyncedAt = book.lastSyncedAt,
)
@@ -59,12 +56,9 @@ class BookMapperImpl @Inject constructor() : BookMapper {
remoteId = bookEntity.remoteId,
libraryId = bookEntity.libraryId,
mediaType = bookEntity.mediaType,
- itemType = bookEntity.itemType,
hasAudio = bookEntity.hasAudio,
hasEbook = bookEntity.hasEbook,
audioDuration = bookEntity.audioDuration,
- audioCurrentFile = bookEntity.audioCurrentFile,
- audioCurrentPosition = bookEntity.audioCurrentPosition,
coverUrl = bookEntity.coverUrl,
lastSyncedAt = bookEntity.lastSyncedAt,
)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt
deleted file mode 100644
index 9a8eb029..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/AudioPlaybackService.kt
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.playback
-
-import android.app.Notification
-import android.app.NotificationChannel
-import android.app.NotificationManager
-import android.app.PendingIntent
-import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import androidx.annotation.OptIn
-import androidx.core.app.NotificationCompat
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.session.MediaSession
-import androidx.media3.session.MediaSessionService
-import dagger.hilt.android.AndroidEntryPoint
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.presentation.main.MainActivity
-import javax.inject.Inject
-
-@AndroidEntryPoint
-@OptIn(UnstableApi::class)
-class AudioPlaybackService : MediaSessionService() {
-
- @Inject
- lateinit var exoPlayer: ExoPlayer
-
- private var mediaSession: MediaSession? = null
-
- override fun onCreate() {
- super.onCreate()
- createNotificationChannel()
- val intent = Intent(this, MainActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
- }
- val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
- } else {
- PendingIntent.FLAG_UPDATE_CURRENT
- }
- val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
- mediaSession = MediaSession.Builder(this, exoPlayer)
- .setSessionActivity(sessionActivity)
- .build()
- }
-
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent?.getParcelableExtra(EXTRA_BOOK)
- }
-
- val notification = buildNotification(book)
- startForeground(NOTIFICATION_ID, notification)
-
- return super.onStartCommand(intent, flags, startId)
- }
-
- override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
- return mediaSession
- }
-
- override fun onTaskRemoved(rootIntent: Intent?) {
- val player = mediaSession?.player ?: exoPlayer
- if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
- stopSelf()
- }
- }
-
- override fun onDestroy() {
- mediaSession?.run {
- player.release()
- release()
- }
- mediaSession = null
- super.onDestroy()
- }
-
- private fun createNotificationChannel() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- val channel = NotificationChannel(
- CHANNEL_ID,
- getString(R.string.audio_playback_channel),
- NotificationManager.IMPORTANCE_LOW
- ).apply {
- description = getString(R.string.audio_playback_channel_description)
- }
- val notificationManager = getSystemService(NotificationManager::class.java)
- notificationManager.createNotificationChannel(channel)
- }
- }
-
- private fun buildNotification(book: Book?): Notification {
- return NotificationCompat.Builder(this, CHANNEL_ID)
- .setContentTitle(book?.title ?: getString(R.string.app_name))
- .setContentText(book?.author?.getAsString() ?: "")
- .setSmallIcon(R.drawable.ic_notification)
- .setOngoing(true)
- .setSilent(true)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .build()
- }
-
- companion object {
- const val EXTRA_BOOK = "extra_book"
- private const val CHANNEL_ID = "audio_playback"
- private const val NOTIFICATION_ID = 1
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt
deleted file mode 100644
index 3eb01d73..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/CacheModule.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package org.dueattendant149.bookshelf.data.playback.di
-
-import android.content.Context
-import androidx.media3.database.StandaloneDatabaseProvider
-import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
-import androidx.media3.datasource.cache.SimpleCache
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import java.io.File
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object CacheModule {
-
- private const val CACHE_DIR_NAME = "audiobook_cache"
- private const val CACHE_SIZE_BYTES = 512L * 1024 * 1024 // 512 MB
-
- @Provides
- @Singleton
- fun provideSimpleCache(
- @ApplicationContext context: Context,
- ): SimpleCache {
- val cacheDir = File(context.externalCacheDir ?: context.cacheDir, CACHE_DIR_NAME)
- if (!cacheDir.exists()) {
- cacheDir.mkdirs()
- }
- return SimpleCache(
- cacheDir,
- LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES),
- StandaloneDatabaseProvider(context),
- )
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt
deleted file mode 100644
index a8c8dfa1..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackAuthProvider.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package org.dueattendant149.bookshelf.data.playback.di
-
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import javax.inject.Inject
-import javax.inject.Singleton
-
-/**
- * In-memory holder for the audiobookshelf bearer token used by playback
- * components. [refresh] must be called after the token changes or before a
- * fresh playback session starts.
- */
-@Singleton
-class PlaybackAuthProvider @Inject constructor() {
-
- @Volatile
- var token: String = ""
- private set
-
- suspend fun refresh(serverSettings: ServerSettings) {
- token = serverSettings.getAbsToken() ?: ""
- }
-
- fun setToken(value: String) {
- token = value.trim()
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt
deleted file mode 100644
index 7e0550cf..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/playback/di/PlaybackModule.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package org.dueattendant149.bookshelf.data.playback.di
-
-import android.content.Context
-import androidx.media3.common.AudioAttributes
-import androidx.media3.common.C
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
-import androidx.media3.datasource.okhttp.OkHttpDataSource
-import dagger.Module
-import dagger.Provides
-import dagger.hilt.InstallIn
-import dagger.hilt.android.qualifiers.ApplicationContext
-import dagger.hilt.components.SingletonComponent
-import okhttp3.OkHttpClient
-import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
-import javax.inject.Singleton
-
-@Module
-@InstallIn(SingletonComponent::class)
-object PlaybackModule {
-
- @Provides
- @Singleton
- fun providePlaybackAuthProvider(): PlaybackAuthProvider = PlaybackAuthProvider()
-
- @OptIn(UnstableApi::class)
- @Provides
- @Singleton
- fun provideExoPlayer(
- @ApplicationContext context: Context,
- authProvider: PlaybackAuthProvider,
- okHttpClient: OkHttpClient,
- ): ExoPlayer {
- val client = okHttpClient.newBuilder()
- .addInterceptor(AuthorizationInterceptor(authProvider::token))
- .build()
-
- val dataSourceFactory = OkHttpDataSource.Factory(client)
- val audioAttributes = AudioAttributes.Builder()
- .setUsage(C.USAGE_MEDIA)
- .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
- .build()
-
- return ExoPlayer.Builder(context)
- .setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
- .setAudioAttributes(audioAttributes, true)
- .setHandleAudioBecomingNoisy(true)
- .build()
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt
deleted file mode 100644
index f646e21f..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/AuthorizationInterceptor.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.remote
-
-import okhttp3.Interceptor
-import okhttp3.Response
-
-/**
- * Adds an `Authorization: Bearer ` header when a non-blank token is
- * available. The token is read lazily on every request so it can be updated
- * without rebuilding the OkHttp client.
- */
-class AuthorizationInterceptor(
- private val tokenProvider: () -> String?,
-) : Interceptor {
-
- override fun intercept(chain: Interceptor.Chain): Response {
- val request = chain.request()
- val token = tokenProvider()?.trim()
- return if (!token.isNullOrBlank()) {
- chain.proceed(
- request.newBuilder()
- .header("Authorization", "Bearer $token")
- .build()
- )
- } else {
- chain.proceed(request)
- }
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt
index 061dfd13..1dbe391f 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/AudiobookshelfApiService.kt
@@ -1,12 +1,8 @@
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
import okhttp3.ResponseBody
-import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
-import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse
import retrofit2.Response
-import retrofit2.http.Body
import retrofit2.http.GET
-import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Streaming
@@ -38,15 +34,4 @@ interface AudiobookshelfApiService {
suspend fun getProgress(
@Path("itemId") itemId: String,
): Response
-
- @GET("api/items/{itemId}")
- suspend fun getItem(
- @Path("itemId") itemId: String,
- ): Response
-
- @POST("api/me/progress/{itemId}")
- suspend fun updateProgress(
- @Path("itemId") itemId: String,
- @Body request: PlaybackProgressUpdateRequest,
- ): Response
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt
deleted file mode 100644
index 39764b3a..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/PlaybackProgressUpdateRequest.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.audiobookshelf
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Request body for updating media progress directly on an Audiobookshelf server.
- *
- * Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
- */
-@Serializable
-data class PlaybackProgressUpdateRequest(
- @SerialName("currentTime")
- val currentTime: Double,
- @SerialName("duration")
- val duration: Double,
- @SerialName("progress")
- val progress: Double,
- @SerialName("episodeId")
- val episodeId: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt
deleted file mode 100644
index 99e92b6d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/audiobookshelf/model/AbsItemResponse.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.audiobookshelf.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class AbsItemResponse(
- val id: String = "",
- val media: AbsMedia? = null,
-)
-
-@Serializable
-data class AbsMedia(
- val metadata: AbsMediaMetadata? = null,
- @SerialName("audioFiles")
- val audioFiles: List = emptyList(),
-)
-
-@Serializable
-data class AbsMediaMetadata(
- val title: String? = null,
-)
-
-@Serializable
-data class AbsAudioFile(
- val ino: String = "",
- val metadata: AbsAudioFileMetadata? = null,
- val duration: Double = 0.0,
-)
-
-@Serializable
-data class AbsAudioFileMetadata(
- val filename: String? = null,
- val ext: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt
index 0f0e3119..2b10badb 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt
@@ -1,17 +1,13 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
import okhttp3.ResponseBody
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.AudioTrackResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
@@ -19,20 +15,15 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesRe
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
-import okhttp3.MultipartBody
-import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
-import retrofit2.http.Multipart
-import retrofit2.http.Part
+import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
-import retrofit2.http.POST
/**
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
@@ -42,54 +33,16 @@ interface BookshelfApiService {
// Health
@GET("health")
- suspend fun health(): Response
+ suspend fun health(): Response
// Libraries / books
@GET("api/v1/books/libraries")
suspend fun getLibraries(): Response>
- @Streaming
- @GET("api/v1/books/{itemId}/ebook")
- suspend fun downloadEbook(
- @Path("itemId") itemId: String,
- ): Response
-
- @GET("api/v1/books/{itemId}/tracks")
- suspend fun getAudioTracks(
- @Path("itemId") itemId: String,
- ): Response>
-
- @Streaming
- @GET("api/v1/books/{itemId}/file/{fileId}")
- suspend fun downloadAudioFile(
- @Path("itemId") itemId: String,
- @Path("fileId") fileId: String,
- ): Response
-
- @Streaming
@GET("api/v1/books/{itemId}")
suspend fun getBook(
@Path("itemId") itemId: String,
- ): Response
-
- @Streaming
- @POST("api/v1/books/{itemId}/progress")
- suspend fun updateReadingProgress(
- @Path("itemId") itemId: String,
- @Body request: ProgressUpdateRequest,
- ): Response
-
- @Streaming
- @POST("api/v1/books/{itemId}/playback-progress")
- suspend fun updatePlaybackProgress(
- @Path("itemId") itemId: String,
- @Body request: PlaybackProgressUpdateRequest,
- ): Response
-
- @GET("api/v1/books/library/{libraryId}/items")
- suspend fun getLibraryItems(
- @Path("libraryId") libraryId: String,
- ): Response
+ ): Response
@GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary(
@@ -130,13 +83,6 @@ interface BookshelfApiService {
@Path("jobId") jobId: String,
): Response
- @Multipart
- @POST("api/v1/upload/book")
- suspend fun uploadBook(
- @Part file: MultipartBody.Part,
- @Part("book_type") bookType: RequestBody,
- ): Response
-
// Downloads / sources
@POST("api/v1/download")
suspend fun startDownload(
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt
deleted file mode 100644
index 6d288f1d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/ServerStatusMonitor.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi
-
-import android.util.Log
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class ServerStatusMonitor
- @Inject
- constructor(
- private val clientFactory: BookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) {
- private val _isOnline = MutableStateFlow(false)
- val isOnline = _isOnline.asStateFlow()
-
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private var pingJob: Job? = null
-
- fun start() {
- if (pingJob != null) return
- pingJob = scope.launch {
- while (true) {
- val online = checkHealth()
- _isOnline.value = online
- delay(30_000L)
- }
- }
- }
-
- fun stop() {
- pingJob?.cancel()
- pingJob = null
- }
-
- private suspend fun checkHealth(): Boolean {
- val url = serverSettings.getBookshelfUrl() ?: return false
- val client = clientFactory.provideClient(url) ?: return false
- return runCatching {
- val response = client.health()
- response.isSuccessful
- }.onFailure {
- Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
- }.getOrDefault(false)
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt
deleted file mode 100644
index 24f86541..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/AudioTrackResponse.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class AudioTrackResponse(
- @SerialName("file_id")
- val fileId: String,
- val title: String = "",
- val duration: Double = 0.0,
- val size: Long = 0L,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt
deleted file mode 100644
index 1735741e..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/PlaybackProgressUpdateRequest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Request body for updating the audio playback progress of a book on the bookshelf-api.
- */
-@Serializable
-data class PlaybackProgressUpdateRequest(
- val itemId: String,
- val libraryId: String,
- @SerialName("current_file")
- val currentFile: String,
- @SerialName("current_position")
- val currentPosition: Long,
- val duration: Long,
- @SerialName("updated_at")
- val updatedAt: Long,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt
deleted file mode 100644
index ca71749b..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/ProgressUpdateRequest.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Request body for updating the local reading progress of a book on the bookshelf-api.
- */
-@Serializable
-data class ProgressUpdateRequest(
- val itemId: String,
- val libraryId: String,
- val scrollIndex: Int,
- val scrollOffset: Int,
- val progress: Float,
- @SerialName("last_chapter")
- val lastChapter: String? = null,
- @SerialName("updated_at")
- val updatedAt: Long,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt
deleted file mode 100644
index 3772973a..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UnifiedItemResponse.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class LibraryItemsResponse(
- val library: LibraryResponse = LibraryResponse(""),
- val items: List = emptyList(),
-)
-
-@Serializable
-data class UnifiedItemResponse(
- val id: String,
- val title: String = "",
- val authors: List = emptyList(),
- val author: String = "",
- val type: String = "",
- @SerialName("media_type")
- val mediaType: String = "",
- @SerialName("library_id")
- val libraryId: String = "",
- @SerialName("cover_url")
- val coverUrl: String = "",
- val duration: Double = 0.0,
- val size: Long = 0L,
- @SerialName("progress")
- val progress: UnifiedItemProgressResponse? = null,
-)
-
-@Serializable
-data class UnifiedItemProgressResponse(
- val reading: ReadingProgressResponse? = null,
- val playback: PlaybackProgressResponse? = null,
-)
-
-@Serializable
-data class ReadingProgressResponse(
- @SerialName("itemId")
- val itemId: String = "",
- @SerialName("libraryId")
- val libraryId: String = "",
- @SerialName("scrollIndex")
- val scrollIndex: Int = 0,
- @SerialName("scrollOffset")
- val scrollOffset: Int = 0,
- val progress: Float = 0f,
- @SerialName("last_chapter")
- val lastChapter: String? = null,
- @SerialName("updatedAt")
- val updatedAt: Long = 0L,
-)
-
-@Serializable
-data class PlaybackProgressResponse(
- @SerialName("itemId")
- val itemId: String = "",
- @SerialName("libraryId")
- val libraryId: String = "",
- @SerialName("current_file")
- val currentFile: String = "",
- @SerialName("current_position")
- val currentPosition: Long = 0L,
- val duration: Long = 0L,
- @SerialName("updatedAt")
- val updatedAt: Long = 0L,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt
deleted file mode 100644
index 09b0b626..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/model/UploadBookResponse.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-@Serializable
-data class UploadBookResponse(
- val success: Boolean = false,
- val filename: String = "",
- val type: String = "",
- val path: String = "",
- @SerialName("error")
- val errorMessage: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt
deleted file mode 100644
index b7602133..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/AudiobookshelfRepositoryImpl.kt
+++ /dev/null
@@ -1,53 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.repository
-
-import android.util.Log
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
-import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class AudiobookshelfRepositoryImpl
- @Inject
- constructor(
- private val clientFactory: BookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) : AudiobookshelfRepository {
- override suspend fun getAudioTracks(itemId: String): Result> {
- return runCatching {
- val bookshelfUrl = serverSettings.getBookshelfUrl()
- if (bookshelfUrl.isNullOrBlank()) {
- return Result.failure(IllegalStateException("Bookshelf API URL is not configured"))
- }
-
- val client = clientFactory.provideClient(bookshelfUrl)
- ?: return Result.failure(IllegalStateException("Failed to create Bookshelf API client"))
-
- val response = client.getAudioTracks(itemId)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to fetch tracks: ${response.code()}"))
- }
-
- response.body().orEmpty()
- .mapIndexed { index, track ->
- AudioTrack(
- fileId = track.fileId,
- title = track.title.takeIf { it.isNotBlank() } ?: "Track ${index + 1}",
- durationMs = (track.duration * 1000).toLong(),
- order = index,
- )
- }
- .sortedBy { it.order }
- }.onFailure {
- Log.e("AudiobookshelfRepo", "getAudioTracks failed", it)
- }
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt
index feee1b91..e3cc4dce 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/BookRepositoryImpl.kt
@@ -92,10 +92,4 @@ class BookRepositoryImpl @Inject constructor(
}
}
}
-
- override suspend fun getBooksWithRemoteId(): Result> = runCatching {
- withContext(Dispatchers.IO) {
- database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
- }
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt
index f2cf6528..f667f68d 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt
@@ -7,26 +7,22 @@
package org.dueattendant149.bookshelf.data.repository
import android.app.Application
-import android.util.Log
+import android.webkit.URLUtil
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.withContext
-import okhttp3.ResponseBody
-import org.dueattendant149.bookshelf.data.local.dto.AudioFileEntity
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
+import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
-import retrofit2.Response
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@@ -38,275 +34,156 @@ class CacheRepositoryImpl
private val application: Application,
private val database: BookDatabase,
private val bookMapper: BookMapper,
- private val apiFactory: BookshelfApiClientFactory,
+ private val apiFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings,
- private val audiobookshelfRepository: AudiobookshelfRepository,
) : CacheRepository {
private val cachedFileDao by lazy { database.cachedFileDao }
- private val audioFileDao by lazy { database.audioFileDao }
- override suspend fun cacheBook(book: Book): Result = runCatching {
- withContext(Dispatchers.IO) {
- require(book.remoteId.isNotBlank()) { "Book has no remote id." }
+ override suspend fun cacheBook(book: Book): Result = runCatching {
+ withContext(Dispatchers.IO) {
+ require(book.hasEbook) { "Book has no ebook." }
+ require(book.remoteId.isNotBlank()) { "Book has no remote id." }
- val existing = database.bookDao.findBookByRemoteId(book.remoteId)
- val localBookId = existing?.id ?: insertLocalBook(book)
+ val existing = database.bookDao.findBookByRemoteId(book.remoteId)
+ val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
- if (book.hasEbook) {
- cacheEbook(book, localBookId)
- }
-
- if (book.hasAudio) {
- cacheAudiobook(book, localBookId)
- }
-
- localBookId
- }
- }.onFailure {
- Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it)
- }
-
- private suspend fun insertLocalBook(book: Book): Int {
- database.bookDao.insertBook(bookMapper.toBookEntity(book))
- return database.bookDao.findBookByRemoteId(book.remoteId)?.id
- ?: throw IllegalStateException("Could not insert book.")
- }
-
- private suspend fun cacheEbook(book: Book, bookId: Int) {
- val existingEbook = database.ebookFileDao.getByBookId(bookId)
- if (existingEbook?.localPath != null && File(existingEbook.localPath).exists()) {
- updateBookFilePath(book, existingEbook.localPath)
- return
- }
-
- val bookshelfUrl = serverSettings.getBookshelfUrl()
- ?: throw IllegalStateException("Bookshelf API URL is not configured.")
- val client = apiFactory.provideClient(bookshelfUrl)
- ?: throw IllegalStateException("Could not create Bookshelf API client.")
-
- val response = client.downloadEbook(book.remoteId)
- if (!response.isSuccessful) {
- throw IllegalStateException("Ebook download failed: ${response.code()}")
- }
- val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.")
-
- val file = saveFileToDir(
- body,
- dirName = "ebooks",
- fileName = guessFileName(response, book.remoteId)
- )
-
- database.ebookFileDao.insert(
- EbookFileEntity(
- bookId = bookId,
- fileId = book.remoteId,
- format = file.extension,
- localPath = file.absolutePath,
- )
- )
-
- cachedFileDao.insert(
- CachedFileEntity(
- bookId = bookId,
- type = "ebook",
- remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook",
- localPath = file.absolutePath,
- status = "completed",
- progress = 1f,
- )
- )
-
- updateBookFilePath(book, file.absolutePath)
- }
-
- private suspend fun cacheAudiobook(book: Book, bookId: Int) {
- val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
- val tracks = tracksResult.getOrThrow()
- if (tracks.isEmpty()) {
- Log.w("CacheRepository", "No audio tracks for ${book.remoteId}")
- return
- }
-
- val bookshelfUrl = serverSettings.getBookshelfUrl()
- ?: throw IllegalStateException("Bookshelf API URL is not configured.")
- val client = apiFactory.provideClient(bookshelfUrl)
- ?: throw IllegalStateException("Could not create Bookshelf API client.")
-
- // Persist track metadata if missing.
- val existingTracks = audioFileDao.getByBookId(bookId)
- if (existingTracks.isEmpty()) {
- audioFileDao.insertAll(
- tracks.mapIndexed { index, track ->
- AudioFileEntity(
- bookId = bookId,
- fileId = track.fileId,
- remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
- duration = track.durationMs / 1000.0,
- order = index,
- )
+ if (existing != null &&
+ cachedEbook?.localPath != null &&
+ File(cachedEbook.localPath).exists()
+ ) {
+ if (existing.filePath != cachedEbook.localPath) {
+ database.bookDao.updateBook(
+ bookMapper.toBookEntity(
+ bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
+ )
+ )
+ }
+ return@withContext existing.id
}
- )
- }
- tracks.forEachIndexed { index, track ->
- val cached = audioFileDao.getByBookId(bookId)
- .firstOrNull { it.fileId == track.fileId }
- if (cached?.localPath != null && File(cached.localPath).exists()) {
- updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath)
- return@forEachIndexed
- }
+ val url = serverSettings.getAbsUrl()
+ val token = serverSettings.getAbsToken()
+ if (url.isNullOrBlank() || token.isNullOrBlank()) {
+ throw IllegalStateException("Audiobookshelf server is not configured.")
+ }
- updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading")
- val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}"
- val response = client.downloadAudioFile(book.remoteId, track.fileId)
- if (!response.isSuccessful) {
- updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
- throw IllegalStateException("Audio track $index download failed: ${response.code()}")
- }
- val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
- val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
+ val client = apiFactory.provideClient(url, token)
+ ?: throw IllegalStateException("Could not create Audiobookshelf client.")
- audioFileDao.update(
- (cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId))
- .copy(localPath = file.absolutePath)
- )
+ val response = client.downloadBook(book.remoteId)
+ if (!response.isSuccessful) {
+ throw IllegalStateException("Download failed: ${response.code()}")
+ }
- val overallProgress = (index + 1).toFloat() / tracks.size
- updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed")
- cachedFileDao.insert(
- CachedFileEntity(
- bookId = bookId,
- type = "audio_${track.fileId}",
- remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
- localPath = file.absolutePath,
- status = "completed",
- progress = overallProgress,
+ val body = response.body()
+ ?: throw IllegalStateException("Download response body is empty.")
+
+ val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
+ val fileName = guessFileName(response, book.remoteId)
+ val file = File(ebooksDir, fileName)
+
+ body.byteStream().use { input ->
+ file.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
+
+ val localBook = if (existing != null) {
+ book.copy(
+ id = existing.id,
+ filePath = file.absolutePath,
+ scrollIndex = existing.scrollIndex,
+ scrollOffset = existing.scrollOffset,
+ progress = existing.progress,
+ categories = existing.categories,
+ )
+ } else {
+ book.copy(filePath = file.absolutePath)
+ }
+
+ database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
+
+ val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
+ ?: throw IllegalStateException("Could not insert or update book.")
+
+ database.ebookFileDao.insert(
+ EbookFileEntity(
+ bookId = inserted.id,
+ fileId = book.remoteId,
+ format = file.extension,
+ localPath = file.absolutePath,
+ )
)
- )
- }
- }
- private suspend fun updateAudioTrackProgress(
- bookId: Int,
- fileId: String,
- progress: Float,
- localPath: String?,
- status: String = "completed",
- ) {
- val type = "audio_$fileId"
- val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull()
- val entity = CachedFileEntity(
- id = existing?.id ?: 0,
- bookId = bookId,
- type = type,
- remoteUrl = existing?.remoteUrl ?: "",
- localPath = localPath ?: existing?.localPath,
- status = status,
- progress = progress,
- )
- if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
- }
-
- private suspend fun updateBookFilePath(book: Book, path: String) {
- val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
- if (existing.filePath != path) {
- database.bookDao.updateBook(
- bookMapper.toBookEntity(
- bookMapper.toBook(existing).copy(filePath = path)
+ cachedFileDao.insert(
+ CachedFileEntity(
+ bookId = inserted.id,
+ type = "ebook",
+ remoteUrl = "$url/api/items/${book.remoteId}/download",
+ localPath = file.absolutePath,
+ status = "completed",
+ progress = 1f,
+ )
)
- )
- }
- }
- private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File {
- val dir = File(application.filesDir, dirName).apply { mkdirs() }
- val file = File(dir, fileName)
- body.byteStream().use { input ->
- file.outputStream().use { output -> input.copyTo(output) }
+ inserted.id
+ }
}
- return file
- }
- private fun guessAudioExtension(fileNameOrTitle: String): String {
- val name = fileNameOrTitle.lowercase()
- return when {
- name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b"
- name.endsWith(".mp3") -> "mp3"
- name.endsWith(".flac") -> "flac"
- name.endsWith(".ogg") -> "ogg"
- name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b"
- else -> "mp3"
- }
- }
-
- override suspend fun deleteCache(remoteId: String): Result = runCatching {
- withContext(Dispatchers.IO) {
- val book = database.bookDao.findBookByRemoteId(remoteId)
- if (book != null) {
+ override suspend fun deleteCache(remoteId: String): Result = runCatching {
+ withContext(Dispatchers.IO) {
+ val book = database.bookDao.findBookByRemoteId(remoteId)
+ ?: throw IllegalStateException("Book not found")
val ebook = database.ebookFileDao.getByBookId(book.id)
val cachedFiles = cachedFileDao.getByBookId(book.id)
- val audioFiles = audioFileDao.getByBookId(book.id)
ebook?.localPath?.let { File(it).delete() }
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
- audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
database.ebookFileDao.deleteByBookId(book.id)
cachedFileDao.deleteByBookId(book.id)
- audioFileDao.deleteByBookId(book.id)
}
}
- }.onFailure {
- Log.e("CacheRepository", "deleteCache failed for $remoteId", it)
- }
- override fun observeCacheStatus(remoteId: String): Flow = flow {
- val book = database.bookDao.findBookByRemoteId(remoteId)
- if (book == null) {
- emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
- return@flow
+ override fun observeCacheStatus(remoteId: String): Flow = flow {
+ val book = database.bookDao.findBookByRemoteId(remoteId)
+ if (book == null) {
+ emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
+ return@flow
+ }
+
+ cachedFileDao.observeByBookId(book.id).collect { entities ->
+ val files = entities.map {
+ CachedFile(
+ type = it.type,
+ status = it.status,
+ progress = it.progress,
+ localPath = it.localPath
+ )
+ }
+ val state = when {
+ entities.isEmpty() -> CacheState.NONE
+ entities.all { it.status == "completed" } -> CacheState.COMPLETED
+ entities.any { it.status == "failed" } -> CacheState.FAILED
+ entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
+ entities.any { it.status == "pending" } -> CacheState.PENDING
+ else -> CacheState.NONE
+ }
+ val progress = if (entities.isEmpty()) {
+ 0f
+ } else {
+ entities.map { it.progress }.average().toFloat()
+ }
+ emit(CacheStatus(remoteId, state, progress, files))
+ }
}
- cachedFileDao.observeByBookId(book.id).collect { entities ->
- val files = entities.map {
- CachedFile(
- type = it.type,
- status = it.status,
- progress = it.progress,
- localPath = it.localPath
- )
- }
- val state = when {
- entities.isEmpty() -> CacheState.NONE
- entities.all { it.status == "completed" } -> CacheState.COMPLETED
- entities.any { it.status == "failed" } -> CacheState.FAILED
- entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
- entities.any { it.status == "pending" } -> CacheState.PENDING
- else -> CacheState.NONE
- }
- val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat()
- emit(CacheStatus(remoteId, state, progress, files))
+ private fun guessFileName(response: retrofit2.Response, remoteId: String): String {
+ val contentDisposition = response.headers()["Content-Disposition"]
+ val mimeType = response.body()?.contentType()?.toString()
+ val url = response.raw().request.url.toString()
+ val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
+ return if (name.contains(".")) name else "$remoteId.epub"
}
}
-
- private fun guessFileName(response: Response, remoteId: String): String {
- val contentDisposition = response.headers()["Content-Disposition"]
- val mimeType = response.body()?.contentType()?.toString()
- val url = response.raw().request.url.toString()
- val name = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType)
- return if (name.contains(".")) name else "$remoteId.epub"
- }
-
- /** Public accessor used by the player to read already-cached tracks. */
- suspend fun getCachedTracks(bookId: Int): List =
- audioFileDao.getCachedByBookId(bookId)
-
- /** Public accessor used by the player to resolve a cached local file for streaming. */
- suspend fun findCachedAudioFile(bookId: Int, fileId: String): File? {
- return audioFileDao.getByBookId(bookId)
- .firstOrNull { it.fileId == fileId }
- ?.localPath
- ?.let { File(it).takeIf { f -> f.exists() } }
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt
deleted file mode 100644
index 67b6466d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/ProgressRepositoryImpl.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.repository
-
-import android.util.Log
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import org.dueattendant149.bookshelf.data.local.room.BookDatabase
-import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class ProgressRepositoryImpl
- @Inject
- constructor(
- private val database: BookDatabase,
- private val bookMapper: BookMapper,
- private val clientFactory: BookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) : ProgressRepository {
- override suspend fun syncReadingProgress(
- book: Book,
- lastChapter: String?,
- ): Result = runCatching {
- withContext(Dispatchers.IO) {
- val client = client() ?: return@withContext
- val request =
- ProgressUpdateRequest(
- itemId = book.remoteId,
- libraryId = book.libraryId,
- scrollIndex = book.scrollIndex,
- scrollOffset = book.scrollOffset,
- progress = book.progress,
- lastChapter = lastChapter,
- updatedAt = System.currentTimeMillis(),
- )
-
- val response = client.updateReadingProgress(book.remoteId, request)
- if (!response.isSuccessful) {
- throw RuntimeException("Failed to sync reading progress: ${response.code()}")
- }
-
- updateLastSyncedAt(book)
- }
- }.onFailure {
- Log.e(TAG, "syncReadingProgress failed for ${book.remoteId}", it)
- }
-
- override suspend fun syncPlaybackProgress(
- book: Book,
- currentFile: String,
- position: Long,
- duration: Long,
- ): Result = runCatching {
- withContext(Dispatchers.IO) {
- val client = client() ?: return@withContext
- val request =
- PlaybackProgressUpdateRequest(
- itemId = book.remoteId,
- libraryId = book.libraryId,
- currentFile = currentFile,
- currentPosition = position,
- duration = duration,
- updatedAt = System.currentTimeMillis(),
- )
-
- val response = client.updatePlaybackProgress(book.remoteId, request)
- if (!response.isSuccessful) {
- throw RuntimeException("Failed to sync playback progress: ${response.code()}")
- }
-
- updateLastSyncedAt(book)
- }
- }.onFailure {
- Log.e(TAG, "syncPlaybackProgress failed for ${book.remoteId}", it)
- }
-
- override suspend fun getBooksWithRemoteId(): Result> = runCatching {
- withContext(Dispatchers.IO) {
- database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
- }
- }.onFailure {
- Log.e(TAG, "getBooksWithRemoteId failed", it)
- }
-
- private suspend fun client() =
- serverSettings.getBookshelfUrl()?.let { url ->
- clientFactory.provideClient(url)
- }
-
- private suspend fun updateLastSyncedAt(book: Book) {
- val entity = bookMapper.toBookEntity(book.copy(lastSyncedAt = System.currentTimeMillis()))
- database.bookDao.updateBook(entity)
- }
-
- companion object {
- private const val TAG = "ProgressRepository"
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt
index 62e4539b..25ac31e4 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteLibraryRepositoryImpl.kt
@@ -7,21 +7,9 @@
package org.dueattendant149.bookshelf.data.repository
import android.util.Log
-import okhttp3.MultipartBody
-import okhttp3.RequestBody
import org.dueattendant149.bookshelf.core.ui.UIText
-import org.dueattendant149.bookshelf.data.local.room.BookDatabase
-import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
@@ -35,8 +23,6 @@ class RemoteLibraryRepositoryImpl
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
- private val database: BookDatabase,
- private val bookMapper: BookMapper,
) : RemoteLibraryRepository {
override suspend fun getLibraries(): Result> {
return runCatching {
@@ -52,42 +38,7 @@ class RemoteLibraryRepositoryImpl
}
override suspend fun getBooks(libraryId: String): Result> {
- return runCatching {
- val client = client() ?: return Result.success(emptyList())
- val response = client.getLibraryItems(libraryId)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to load library items: ${response.code()}"))
- }
-
- val body = response.body() ?: LibraryItemsResponse()
- val items = body.items.map { it.toBook() }
-
- // Upsert each item into the local DB keyed by remoteId,
- // preserving local file path/progress if the book already exists.
- items.forEach { book ->
- val existing = database.bookDao.findBookByRemoteId(book.remoteId)
- val toStore = if (existing != null) {
- val existingBook = bookMapper.toBook(existing)
- book.copy(
- id = existingBook.id,
- filePath = existingBook.filePath,
- scrollIndex = existingBook.scrollIndex,
- scrollOffset = existingBook.scrollOffset,
- progress = existingBook.progress,
- categories = existingBook.categories,
- lastOpened = existingBook.lastOpened,
- )
- } else {
- book
- }
- database.bookDao.insertBook(bookMapper.toBookEntity(toStore))
- }
-
- Result.success(items)
- }.getOrElse {
- Log.e("RemoteLibraryRepo", "getBooks failed", it)
- Result.failure(it)
- }
+ return searchLibrary(libraryId, "")
}
override suspend fun searchLibrary(
@@ -106,59 +57,6 @@ class RemoteLibraryRepositoryImpl
}
}
- override suspend fun search(request: SearchRequest): Result {
- return runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.search(request)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Search failed: ${response.code()}"))
- }
- response.body() ?: SearchResponse()
- }.onFailure {
- Log.e("RemoteLibraryRepo", "search failed", it)
- }
- }
-
- override suspend fun startDownload(request: DownloadRequest): Result {
- return runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.startDownload(request)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Download failed: ${response.code()}"))
- }
- response.body() ?: DownloadResponse()
- }.onFailure {
- Log.e("RemoteLibraryRepo", "startDownload failed", it)
- }
- }
-
- override suspend fun listDownloads(): Result {
- return runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.listDownloads()
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("List downloads failed: ${response.code()}"))
- }
- response.body() ?: DownloadsListResponse()
- }.onFailure {
- Log.e("RemoteLibraryRepo", "listDownloads failed", it)
- }
- }
-
- override suspend fun uploadBook(
- file: MultipartBody.Part,
- bookType: RequestBody,
- ): Result = runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.uploadBook(file, bookType)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Upload failed: ${response.code()}"))
- }
- response.body() ?: UploadBookResponse(success = false, errorMessage = "Empty response")
- }.onFailure {
- Log.e("RemoteLibraryRepo", "uploadBook failed", it)
- }
-
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
private fun LibraryResponse.toDomain() =
@@ -169,54 +67,7 @@ class RemoteLibraryRepositoryImpl
itemCount = itemCount,
)
- private suspend fun UnifiedItemResponse.toBook(): Book {
- val resolvedCoverUrl = resolveCoverUrl(coverUrl)
- return Book(
- id = 0,
- title = title,
- author = UIText.StringValue(
- author.takeIf { it.isNotBlank() }
- ?: authors.filter { it.isNotBlank() }.joinToString(", ")
- .takeIf { it.isNotBlank() }
- ?: ""
- ),
- description = null,
- filePath = "",
- coverImage = null,
- scrollIndex = progress?.reading?.scrollIndex ?: 0,
- scrollOffset = progress?.reading?.scrollOffset ?: 0,
- progress = progress?.reading?.progress ?: 0f,
- lastOpened = null,
- categories = emptyList(),
- remoteId = id,
- libraryId = libraryId,
- mediaType = mediaType,
- itemType = type,
- hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid",
- hasEbook = type == "ebook" || type == "hybrid",
- audioDuration = progress?.playback?.duration ?: duration.toLong(),
- audioCurrentFile = progress?.playback?.currentFile ?: "",
- audioCurrentPosition = progress?.playback?.currentPosition ?: 0L,
- coverUrl = resolvedCoverUrl,
- lastSyncedAt = System.currentTimeMillis(),
- )
- }
-
- private suspend fun resolveCoverUrl(coverUrl: String): String {
- if (coverUrl.isBlank()) return ""
- val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl
- val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/')
- return when {
- coverUrl.startsWith("http://", ignoreCase = true) ||
- coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl
-
- coverUrl.startsWith("/") -> "$base$coverUrl"
-
- else -> "$base/$coverUrl"
- }
- }
-
- private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
+ private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
Book(
id = 0,
title = title,
@@ -232,7 +83,6 @@ class RemoteLibraryRepositoryImpl
remoteId = id,
libraryId = libraryId,
mediaType = mediaType,
- itemType = mediaType,
hasAudio = mediaType == "audiobook" || mediaType == "podcast",
hasEbook = mediaType == "book",
audioDuration = duration.toLong(),
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt
deleted file mode 100644
index 36a6a16a..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/RemoteTtsRepositoryImpl.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.repository
-
-import android.util.Log
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEngineResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoiceResponse
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
-import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
-import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class RemoteTtsRepositoryImpl
- @Inject
- constructor(
- private val clientFactory: BookshelfApiClientFactory,
- private val serverSettings: ServerSettings,
- ) : RemoteTtsRepository {
- override suspend fun fetchEngines(): Result> {
- return runCatching {
- val client = client() ?: return Result.success(emptyList())
- val response = client.getTtsEngines()
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to fetch TTS engines: ${response.code()}"))
- }
- response.body()?.engines.orEmpty().map { it.toDomain() }
- }.onFailure {
- Log.e("RemoteTtsRepo", "fetchEngines failed", it)
- }
- }
-
- override suspend fun fetchVoices(engine: String?): Result> {
- return runCatching {
- val client = client() ?: return Result.success(emptyList())
- val response = client.getTtsVoices(engine)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to fetch TTS voices: ${response.code()}"))
- }
- response.body()?.voices.orEmpty().map { it.toDomain() }
- }.onFailure {
- Log.e("RemoteTtsRepo", "fetchVoices failed", it)
- }
- }
-
- override suspend fun createJob(
- bookId: String,
- engine: String,
- voiceId: String,
- speed: Double,
- ): Result {
- return runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.createTtsJob(
- TtsCreateRequest(
- bookId = bookId,
- engine = engine,
- voiceId = voiceId,
- speed = speed,
- )
- )
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to create TTS job: ${response.code()}"))
- }
- val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response"))
- body.toDomain()
- }.onFailure {
- Log.e("RemoteTtsRepo", "createJob failed", it)
- }
- }
-
- override suspend fun getJob(jobId: String): Result {
- return runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.getTtsJobStatus(jobId)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to fetch TTS job: ${response.code()}"))
- }
- val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response"))
- body.toDomain()
- }.onFailure {
- Log.e("RemoteTtsRepo", "getJob failed", it)
- }
- }
-
- override suspend fun downloadAudio(jobId: String): Result {
- return runCatching {
- val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
- val response = client.downloadTtsAudio(jobId)
- if (!response.isSuccessful) {
- return Result.failure(RuntimeException("Failed to download TTS audio: ${response.code()}"))
- }
- response.body()?.bytes() ?: return Result.failure(RuntimeException("Empty TTS audio response"))
- }.onFailure {
- Log.e("RemoteTtsRepo", "downloadAudio failed", it)
- }
- }
-
- private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
-
- private fun TtsEngineResponse.toDomain() =
- TtsEngine(
- id = id,
- name = name,
- supportsStreaming = capabilities.supportsStreaming,
- supportsCloning = capabilities.supportsCloning,
- maxTextLength = capabilities.maxTextLength,
- needsNetwork = capabilities.needsNetwork,
- )
-
- private fun TtsVoiceResponse.toDomain() =
- TtsVoice(
- id = id,
- name = name,
- language = language,
- engine = engine,
- gender = gender,
- quality = quality,
- requiresReference = requiresReference,
- )
-
- private fun TtsJobResponse.toDomain() =
- TtsJob(
- jobId = jobId,
- bookId = bookId,
- title = title,
- author = author,
- engine = engine,
- voiceId = voiceId,
- speed = speed,
- status = status,
- progress = progress,
- currentChapter = currentChapter,
- totalChapters = totalChapters,
- completedChapters = completedChapters,
- outputPath = outputPath,
- error = error,
- createdAt = createdAt,
- startedAt = startedAt,
- completedAt = completedAt,
- )
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt
index 2ee0ca81..05ed56a8 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt
@@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.data.settings
import androidx.datastore.preferences.core.stringPreferencesKey
import org.dueattendant149.bookshelf.data.local.data_store.DataStore
-import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
import org.dueattendant149.bookshelf.domain.util.isValidUri
import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject
@@ -48,12 +47,11 @@ class ServerSettings
* or null when all credentials are present.
*/
suspend fun getMissingCredentialsError(): String? {
- val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)?.trim()
+ val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)
if (bookshelfUrl.isNullOrBlank()) {
return "Bookshelf API URL is missing"
}
- val normalizedBookshelfUrl = bookshelfUrl.ensureUriScheme()
- if (!normalizedBookshelfUrl.isValidUri()) {
+ if (!bookshelfUrl.isValidUri()) {
return "Bookshelf API URL is invalid: $bookshelfUrl"
}
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
@@ -69,8 +67,8 @@ class ServerSettings
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
return when {
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
- !url.ensureUriScheme().isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
- else -> Result.success(url.ensureUriScheme().normalizeUri())
+ !url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
+ else -> Result.success(url.normalizeUri())
}
}
@@ -82,8 +80,6 @@ class ServerSettings
private fun String.normalizeIfValid(): String? {
val trimmed = trim()
- if (trimmed.isBlank()) return null
- val withScheme = trimmed.ensureUriScheme()
- return if (withScheme.isValidUri()) withScheme.normalizeUri() else null
+ return if (trimmed.isValidUri()) trimmed.normalizeUri() else null
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt
index 4740de2c..2a6165da 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/SettingsManager.kt
@@ -180,26 +180,6 @@ class SettingsManager @Inject constructor(
val perceptionExpanderThickness = setting(
key = intPreferencesKey("perception_expander_thickness"), default = 4
)
-
- // RSVP (speed reading)
- val rsvpWpm = setting(
- key = intPreferencesKey("rsvp_wpm"), default = 350
- )
- val rsvpFontSize = setting(
- key = intPreferencesKey("rsvp_font_size"), default = 56
- )
- val rsvpPauseOnParagraph = setting(
- key = booleanPreferencesKey("rsvp_pause_paragraph"), default = true
- )
- val rsvpPauseOnChapter = setting(
- key = booleanPreferencesKey("rsvp_pause_chapter"), default = true
- )
- val rsvpPauseOnLongWords = setting(
- key = booleanPreferencesKey("rsvp_pause_long_words"), default = true
- )
- val rsvpShowFocusGuide = setting(
- key = booleanPreferencesKey("rsvp_show_focus_guide"), default = true
- )
val horizontalLimiter = setting(
key = booleanPreferencesKey("horizontal_limiter"), default = false
)
@@ -231,9 +211,6 @@ class SettingsManager @Inject constructor(
key = doublePreferencesKey("screen_brightness"), default = 0.5f,
serialize = { it.toDouble() }, deserialize = { it.toFloat() }
)
- val readerPagination = setting(
- key = booleanPreferencesKey("reader_pagination"), default = true
- )
val horizontalGesture = setting(
key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF,
serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt
index 67b26258..03fd76e7 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt
@@ -96,14 +96,13 @@ class CacheDownloadWorker(
}
if (hasAudio) {
- downloadAudioTracks(
+ downloadFile(
database = database,
bookId = bookId,
- absUrl = absUrl,
- remoteId = remoteId,
- bookDir = bookDir,
- client = client,
- )
+ type = "audio",
+ remoteUrl = "$absUrl/api/items/$remoteId/download",
+ localFile = File(bookDir, "audio"),
+ ) { client.downloadBook(remoteId) }
}
Result.success()
@@ -113,65 +112,6 @@ class CacheDownloadWorker(
}
}
- private suspend fun downloadAudioTracks(
- database: BookDatabase,
- bookId: Int,
- absUrl: String,
- remoteId: String,
- bookDir: File,
- client: org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiService,
- ) {
- val audioDir = File(bookDir, "audio").apply { mkdirs() }
-
- val itemResponse = client.getItem(remoteId)
- if (!itemResponse.isSuccessful) {
- throw IOException("Failed to fetch item for audio tracks: ${itemResponse.code()}")
- }
- val item = itemResponse.body() ?: throw IOException("Empty item response")
- val audioFiles = item.media?.audioFiles.orEmpty()
-
- if (audioFiles.isEmpty()) {
- Log.w(TAG, "No audio files found for book $bookId")
- return
- }
-
- val totalTracks = audioFiles.size
- audioFiles.forEachIndexed { index, file ->
- val fileId = file.ino
- val trackFile = File(audioDir, "${index}_${fileId}")
- val record = getOrCreateRecord(
- database,
- bookId,
- "audio_${index}",
- "$absUrl/api/items/$remoteId/file/$fileId",
- )
-
- if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) {
- return@forEachIndexed
- }
-
- database.cachedFileDao.update(record.copy(status = "downloading", progress = index.toFloat() / totalTracks))
-
- val response = client.downloadAudioFile(remoteId, fileId)
- if (!response.isSuccessful) {
- throw IOException("Audio track $index download failed: ${response.code()}")
- }
- val body = response.body() ?: throw IOException("Empty audio response body")
-
- body.byteStream().use { input ->
- trackFile.outputStream().use { output -> input.copyTo(output) }
- }
-
- database.cachedFileDao.update(
- record.copy(
- status = "completed",
- progress = (index + 1).toFloat() / totalTracks,
- localPath = trackFile.absolutePath,
- ),
- )
- }
- }
-
private suspend fun downloadFile(
database: BookDatabase,
bookId: Int,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt
deleted file mode 100644
index 362729e1..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/ProgressSyncWorker.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.worker
-
-import android.content.Context
-import android.util.Log
-import androidx.work.Constraints
-import androidx.work.CoroutineWorker
-import androidx.work.ExistingPeriodicWorkPolicy
-import androidx.work.NetworkType
-import androidx.work.PeriodicWorkRequestBuilder
-import androidx.work.WorkManager
-import androidx.work.WorkerParameters
-import dagger.hilt.EntryPoint
-import dagger.hilt.InstallIn
-import dagger.hilt.android.EntryPointAccessors
-import dagger.hilt.components.SingletonComponent
-import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
-import java.util.concurrent.TimeUnit
-
-/**
- * Periodic worker that syncs reading and playback progress for all books
- * that have a remote (bookshelf-api) identifier.
- */
-class ProgressSyncWorker(
- context: Context,
- params: WorkerParameters,
-) : CoroutineWorker(context, params) {
- companion object {
- const val WORK_NAME = "progress-sync-worker"
-
- fun schedule(workManager: WorkManager) {
- val request =
- PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES)
- .setConstraints(
- Constraints
- .Builder()
- .setRequiredNetworkType(NetworkType.CONNECTED)
- .build(),
- )
- .build()
-
- workManager.enqueueUniquePeriodicWork(
- WORK_NAME,
- ExistingPeriodicWorkPolicy.KEEP,
- request,
- )
- }
- }
-
- private val entryPoint: ProgressSyncEntryPoint by lazy {
- EntryPointAccessors.fromApplication(applicationContext, ProgressSyncEntryPoint::class.java)
- }
-
- override suspend fun doWork(): Result {
- val syncReading = entryPoint.syncReadingProgressUseCase()
- val syncPlayback = entryPoint.syncPlaybackProgressUseCase()
-
- val books = entryPoint.progressRepository().getBooksWithRemoteId().getOrElse {
- Log.e(TAG, "Failed to load books with remote IDs", it)
- return Result.retry()
- }
-
- var failures = 0
- books.forEach { book ->
- syncReading(book, lastChapter = null).onFailure {
- Log.w(TAG, "Reading progress sync failed for ${book.remoteId}", it)
- failures++
- }
-
- if (book.hasAudio && book.audioDuration > 0L) {
- syncPlayback(
- book = book,
- currentFile = book.audioCurrentFile,
- position = book.audioCurrentPosition,
- duration = book.audioDuration,
- ).onFailure {
- Log.w(TAG, "Playback progress sync failed for ${book.remoteId}", it)
- failures++
- }
- }
- }
-
- return if (failures == 0) {
- Result.success()
- } else {
- Log.w(TAG, "$failures/${books.size} books failed to sync, retrying")
- Result.retry()
- }
- }
-
- @EntryPoint
- @InstallIn(SingletonComponent::class)
- interface ProgressSyncEntryPoint {
- fun syncReadingProgressUseCase(): SyncReadingProgressUseCase
- fun syncPlaybackProgressUseCase(): SyncPlaybackProgressUseCase
- fun progressRepository(): org.dueattendant149.bookshelf.domain.repository.ProgressRepository
- }
-}
-
-private const val TAG = "ProgressSyncWorker"
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt
deleted file mode 100644
index 6c88e57b..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/TtsDownloadWorker.kt
+++ /dev/null
@@ -1,160 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.data.worker
-
-import android.content.Context
-import android.util.Log
-import androidx.hilt.work.HiltWorker
-import androidx.work.CoroutineWorker
-import androidx.work.Data
-import androidx.work.WorkerParameters
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import kotlinx.coroutines.delay
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
-import java.io.File
-
-@HiltWorker
-class TtsDownloadWorker
- @AssistedInject
- constructor(
- @Assisted context: Context,
- @Assisted params: WorkerParameters,
- private val remoteTtsRepository: RemoteTtsRepository,
- ) : CoroutineWorker(context, params) {
-
- override suspend fun doWork(): Result {
- val jobId = inputData.getString(KEY_JOB_ID)
- ?: return Result.failure(errorData("Missing job ID"))
- val bookId = inputData.getString(KEY_BOOK_ID) ?: ""
-
- val maxAttempts = inputData.getInt(KEY_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS)
- val pollIntervalMs = inputData.getLong(KEY_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS)
-
- repeat(maxAttempts) { attempt ->
- val result = remoteTtsRepository.getJob(jobId)
- val job = result.getOrElse {
- Log.e(TAG, "Failed to poll job $jobId (attempt $attempt)", it)
- return Result.retry()
- }
-
- setProgress(
- progressData(
- jobId = jobId,
- status = job.status,
- progress = job.progress,
- currentChapter = job.currentChapter,
- completedChapters = job.completedChapters,
- totalChapters = job.totalChapters,
- )
- )
-
- when {
- job.isCompleted -> {
- val downloadResult = remoteTtsRepository.downloadAudio(jobId)
- val audioBytes = downloadResult.getOrElse {
- Log.e(TAG, "Failed to download audio for job $jobId", it)
- return Result.failure(errorData(it.message ?: "Download failed"))
- }
-
- val outputFile = outputFile(bookId, jobId)
- outputFile.parentFile?.mkdirs()
- outputFile.writeBytes(audioBytes)
-
- Log.i(TAG, "Saved TTS audio for job $jobId to ${outputFile.absolutePath}")
- return Result.success(
- successData(
- jobId = jobId,
- filePath = outputFile.absolutePath,
- )
- )
- }
-
- job.isFailed -> {
- Log.e(TAG, "TTS job $jobId failed: ${job.error}")
- return Result.failure(errorData(job.error.ifBlank { "TTS job failed" }))
- }
-
- else -> {
- delay(pollIntervalMs)
- }
- }
- }
-
- return Result.failure(errorData("TTS job polling timed out"))
- }
-
- private fun outputFile(bookId: String, jobId: String): File {
- val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
- val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
- val dir = File(applicationContext.cacheDir, "tts")
- return File(dir, "${safeBookId}_${safeJobId}.m4b")
- }
-
- companion object {
- const val TAG = "TtsDownloadWorker"
-
- const val KEY_JOB_ID = "job_id"
- const val KEY_BOOK_ID = "book_id"
- const val KEY_MAX_ATTEMPTS = "max_attempts"
- const val KEY_POLL_INTERVAL_MS = "poll_interval_ms"
-
- const val KEY_OUTPUT_FILE_PATH = "output_file_path"
- const val KEY_OUTPUT_JOB_ID = "output_job_id"
- const val KEY_ERROR_MESSAGE = "error_message"
-
- const val PROGRESS_STATUS = "progress_status"
- const val PROGRESS_PROGRESS = "progress_progress"
- const val PROGRESS_CURRENT_CHAPTER = "progress_current_chapter"
- const val PROGRESS_COMPLETED_CHAPTERS = "progress_completed_chapters"
- const val PROGRESS_TOTAL_CHAPTERS = "progress_total_chapters"
-
- private const val DEFAULT_MAX_ATTEMPTS = 360
- private const val DEFAULT_POLL_INTERVAL_MS = 5_000L
-
- fun createInputData(
- jobId: String,
- bookId: String,
- maxAttempts: Int = DEFAULT_MAX_ATTEMPTS,
- pollIntervalMs: Long = DEFAULT_POLL_INTERVAL_MS,
- ): Data =
- Data.Builder()
- .putString(KEY_JOB_ID, jobId)
- .putString(KEY_BOOK_ID, bookId)
- .putInt(KEY_MAX_ATTEMPTS, maxAttempts)
- .putLong(KEY_POLL_INTERVAL_MS, pollIntervalMs)
- .build()
-
- private fun progressData(
- jobId: String,
- status: String,
- progress: Double,
- currentChapter: String,
- completedChapters: Int,
- totalChapters: Int,
- ): Data =
- Data.Builder()
- .putString(KEY_JOB_ID, jobId)
- .putString(PROGRESS_STATUS, status)
- .putDouble(PROGRESS_PROGRESS, progress)
- .putString(PROGRESS_CURRENT_CHAPTER, currentChapter)
- .putInt(PROGRESS_COMPLETED_CHAPTERS, completedChapters)
- .putInt(PROGRESS_TOTAL_CHAPTERS, totalChapters)
- .build()
-
- private fun successData(jobId: String, filePath: String): Data =
- Data.Builder()
- .putString(KEY_OUTPUT_JOB_ID, jobId)
- .putString(KEY_OUTPUT_FILE_PATH, filePath)
- .build()
-
- private fun errorData(message: String): Data =
- Data.Builder()
- .putString(KEY_ERROR_MESSAGE, message)
- .build()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt
index 120299a0..0993c05c 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/library/Book.kt
@@ -35,12 +35,9 @@ data class Book(
val remoteId: String = "",
val libraryId: String = "",
val mediaType: String = "",
- val itemType: String = "",
val hasAudio: Boolean = false,
val hasEbook: Boolean = false,
val audioDuration: Long = 0L,
- val audioCurrentFile: String = "",
- val audioCurrentPosition: Long = 0L,
val coverUrl: String = "",
val lastSyncedAt: Long = 0L,
) : Parcelable {
@@ -60,12 +57,9 @@ data class Book(
remoteId = "",
libraryId = "",
mediaType = "",
- itemType = "",
hasAudio = false,
hasEbook = false,
audioDuration = 0L,
- audioCurrentFile = "",
- audioCurrentPosition = 0L,
coverUrl = "",
lastSyncedAt = 0L,
)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt
deleted file mode 100644
index 2dec6b92..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioPlayerProgress.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.model.player
-
-/**
- * Placeholder for the audio player progress state.
- *
- * TODO: When implementing the ExoPlayer/MediaSession audio player,
- * hook [org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase]
- * into the playback position update flow, e.g.:
- *
- * ```
- * val book = ...
- * val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
- * val position = player.currentPosition.coerceAtLeast(0L)
- * val duration = player.duration.coerceAtLeast(book.audioDuration)
- * syncPlaybackProgressUseCase(book, currentFile, position, duration)
- * ```
- */
-data class AudioPlayerProgress(
- val bookId: Int,
- val currentFile: String,
- val position: Long,
- val duration: Long,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt
deleted file mode 100644
index 1f7b57e6..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/player/AudioTrack.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package org.dueattendant149.bookshelf.domain.model.player
-
-data class AudioTrack(
- val fileId: String,
- val title: String,
- val durationMs: Long,
- val order: Int,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt
deleted file mode 100644
index f1e3563e..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/reader/ReaderPage.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.model.reader
-
-import androidx.compose.runtime.Immutable
-
-@Immutable
-data class ReaderPage(
- val index: Int,
- val items: List,
- val startTextIndex: Int,
- val endTextIndex: Int,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt
deleted file mode 100644
index 75baba3e..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpEngine.kt
+++ /dev/null
@@ -1,173 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.model.rsvp
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-
-/**
- * Drives RSVP playback for a fixed token stream.
- *
- * @param scope scope used for the playback loop (typically `viewModelScope`).
- */
-class RsvpEngine(private val scope: CoroutineScope) {
-
- data class State(
- val currentIndex: Int = 0,
- val isPlaying: Boolean = false,
- val tokens: List = emptyList(),
- val wpm: Int = 350,
- val pauseOnParagraphEnd: Boolean = true,
- val pauseOnChapterEnd: Boolean = true,
- val pauseOnLongWords: Boolean = true,
- ) {
- val progress: Float
- get() = if (tokens.isEmpty()) 0f
- else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
-
- val currentToken: RsvpToken?
- get() = tokens.getOrNull(currentIndex)
-
- val isAtEnd: Boolean
- get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
- }
-
- private val _state = MutableStateFlow(State())
- val state: StateFlow = _state.asStateFlow()
-
- private var playbackJob: Job? = null
-
- fun setTokens(tokens: List) {
- stop()
- _state.value = _state.value.copy(
- tokens = tokens,
- currentIndex = 0,
- isPlaying = false,
- )
- }
-
- fun setWpm(wpm: Int) {
- _state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
- }
-
- fun setPauseOnParagraphEnd(value: Boolean) {
- _state.value = _state.value.copy(pauseOnParagraphEnd = value)
- }
-
- fun setPauseOnChapterEnd(value: Boolean) {
- _state.value = _state.value.copy(pauseOnChapterEnd = value)
- }
-
- fun setPauseOnLongWords(value: Boolean) {
- _state.value = _state.value.copy(pauseOnLongWords = value)
- }
-
- fun seekToIndex(index: Int) {
- val s = _state.value
- if (s.tokens.isEmpty()) return
- val clamped = index.coerceIn(0, s.tokens.lastIndex)
- _state.value = s.copy(currentIndex = clamped)
- }
-
- fun seekToProgress(progress: Float) {
- val s = _state.value
- if (s.tokens.isEmpty()) return
- val clamped = progress.coerceIn(0f, 1f)
- val idx = (clamped * s.tokens.lastIndex).toInt()
- _state.value = s.copy(currentIndex = idx)
- }
-
- fun skipForward(count: Int = 10) {
- seekToIndex(_state.value.currentIndex + count)
- }
-
- fun skipBackward(count: Int = 10) {
- seekToIndex(_state.value.currentIndex - count)
- }
-
- fun play() {
- val s = _state.value
- if (s.tokens.isEmpty()) return
- if (s.isAtEnd) {
- seekToIndex(0)
- }
- if (_state.value.isPlaying) return
- _state.value = _state.value.copy(isPlaying = true)
- startPlaybackLoop()
- }
-
- fun pause() {
- _state.value = _state.value.copy(isPlaying = false)
- playbackJob?.cancel()
- playbackJob = null
- }
-
- fun toggle() {
- if (_state.value.isPlaying) pause() else play()
- }
-
- fun stop() {
- pause()
- }
-
- private fun startPlaybackLoop() {
- playbackJob?.cancel()
- playbackJob = scope.launch {
- while (isActive && _state.value.isPlaying) {
- val snapshot = _state.value
- if (snapshot.isAtEnd) {
- _state.value = snapshot.copy(isPlaying = false)
- return@launch
- }
-
- val token = snapshot.tokens[snapshot.currentIndex]
- delay(delayMsFor(token, snapshot))
-
- val after = _state.value
- if (!after.isPlaying) return@launch
- if (after.tokens.isEmpty()) return@launch
-
- _state.value = after.copy(
- currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
- )
- }
- }
- }
-
- /**
- * Compute the delay (in ms) before advancing past [token].
- *
- * Base = 60_000 / wpm, adjusted by:
- * - per-word multiplier (long words / digits)
- * - paragraph-end bonus (configurable, default +200 ms)
- * - chapter-end bonus (configurable, default +400 ms)
- */
- private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
- val wpm = snapshot.wpm.coerceAtLeast(50)
- val baseMs = 60_000.0 / wpm
- var ms = baseMs * token.multiplier
-
- if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
- ms += 200.0
- }
- if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
- ms += 400.0
- }
- if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
- // multiplier already applied above; keep it
- } else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
- ms = baseMs
- }
- return ms.toLong().coerceAtLeast(20L)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt
deleted file mode 100644
index 8fe70e5b..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpToken.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.model.rsvp
-
-import androidx.compose.runtime.Immutable
-
-/**
- * One word/segment in the RSVP stream.
- *
- * @param word the original word as it appears in the book.
- * @param pivotIndex index of the optimal recognition point within [word];
- * characters before are [prefix], the pivot is [pivot] char, after are [suffix].
- * @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
- * @param isChapterEnd true if the token follows a chapter heading.
- * @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
- */
-@Immutable
-data class RsvpToken(
- val word: String,
- val pivotIndex: Int,
- val isParagraphEnd: Boolean,
- val isChapterEnd: Boolean,
- val multiplier: Float = 1f,
-) {
- val prefix: String
- get() = word.substring(0, pivotIndex)
-
- val pivot: String
- get() = word.substring(pivotIndex, pivotIndex + 1)
-
- val suffix: String
- get() = word.substring(pivotIndex + 1)
-}
-
-/**
- * Optimal recognition point (ORP) lookup for word lengths 1..13+.
- * Indices are 0-based positions of the focal character.
- * Values follow the heuristic table from Spritz/speed-reading literature:
- * 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
- */
-object OrpTable {
- private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
-
- fun pivotFor(word: String): Int {
- val len = word.length
- if (len == 0) return 0
- if (len <= TABLE.size) return TABLE[len - 1]
- return (len * 0.3f).toInt().coerceIn(1, len - 1)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt
deleted file mode 100644
index bc9e0352..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/rsvp/RsvpTokenizer.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.model.rsvp
-
-import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
-
-/**
- * Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
- * into a flat list of [RsvpToken]s suitable for RSVP playback.
- *
- * - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
- * - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
- * - Marks the first word after a chapter heading as `isChapterEnd = true`.
- * - Strips punctuation-only tokens and empty whitespace tokens.
- */
-object RsvpTokenizer {
-
- /**
- * Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
- */
- fun tokenizeReaderText(blocks: List): List {
- val tokens = ArrayList(blocks.size * 8)
- var lastWasParagraphEnd = false
- var pendingChapterEnd = false
-
- for (block in blocks) {
- when (block) {
- is ReaderText.Chapter -> {
- pendingChapterEnd = true
- lastWasParagraphEnd = false
- }
-
- is ReaderText.Text -> {
- val raw = block.line.text
- if (raw.isBlank()) {
- lastWasParagraphEnd = true
- } else {
- tokenizeLine(
- line = raw,
- isParagraphEnd = lastWasParagraphEnd,
- isChapterEnd = pendingChapterEnd,
- out = tokens,
- )
- lastWasParagraphEnd = false
- pendingChapterEnd = false
- }
- }
-
- is ReaderText.Separator -> lastWasParagraphEnd = true
- is ReaderText.Image -> Unit
- }
- }
-
- return tokens
- }
-
- private fun tokenizeLine(
- line: String,
- isParagraphEnd: Boolean,
- isChapterEnd: Boolean,
- out: MutableList,
- ) {
- val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
- if (words.isEmpty()) return
-
- val lastIndex = words.lastIndex
- for ((idx, word) in words.withIndex()) {
- val cleaned = cleanWord(word)
- if (cleaned.isEmpty()) continue
- val pivot = OrpTable.pivotFor(cleaned)
- val paragraphEnd = isParagraphEnd && idx == lastIndex
- val multiplier = computeMultiplier(cleaned)
- out.add(
- RsvpToken(
- word = cleaned,
- pivotIndex = pivot,
- isParagraphEnd = paragraphEnd,
- isChapterEnd = isChapterEnd && idx == 0,
- multiplier = multiplier,
- )
- )
- }
- }
-
- private val WORD_SPLIT_REGEX = Regex("\\s+")
-
- private fun cleanWord(raw: String): String {
- val sb = StringBuilder(raw.length)
- var i = 0
- val len = raw.length
- while (i < len) {
- val c = raw[i]
- if (c.isLetterOrDigit() || c == '-' || c == '\'') {
- sb.append(c)
- }
- i++
- }
- return sb.toString()
- }
-
- /**
- * Per-word pacing multiplier.
- *
- * - Long words (>9 chars) get +30% time for recognition.
- * - Numbers/digits get +20% time.
- * - Baseline 1.0 for normal words.
- */
- private fun computeMultiplier(word: String): Float {
- val len = word.length
- val base = when {
- len > 9 -> 1.3f
- len > 6 -> 1.1f
- else -> 1.0f
- }
- val hasDigit = word.any { it.isDigit() }
- return if (hasDigit) base + 0.2f else base
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt
deleted file mode 100644
index eb3c447d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/tts/TtsModels.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.model.tts
-
-data class TtsEngine(
- val id: String,
- val name: String,
- val supportsStreaming: Boolean,
- val supportsCloning: Boolean,
- val maxTextLength: Int,
- val needsNetwork: Boolean,
-)
-
-data class TtsVoice(
- val id: String,
- val name: String,
- val language: String,
- val engine: String,
- val gender: String,
- val quality: String,
- val requiresReference: Boolean,
-)
-
-data class TtsJob(
- val jobId: String,
- val bookId: String,
- val title: String,
- val author: String,
- val engine: String,
- val voiceId: String,
- val speed: Double,
- val status: String,
- val progress: Double,
- val currentChapter: String,
- val totalChapters: Int,
- val completedChapters: Int,
- val outputPath: String,
- val error: String,
- val createdAt: Double,
- val startedAt: Double,
- val completedAt: Double,
-) {
- val isCompleted: Boolean get() = status == "completed"
- val isFailed: Boolean get() = status == "failed" || status == "error"
- val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt
deleted file mode 100644
index 8e48d6e7..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/AudiobookshelfRepository.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.dueattendant149.bookshelf.domain.repository
-
-import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
-
-interface AudiobookshelfRepository {
- suspend fun getAudioTracks(itemId: String): Result>
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt
index 1c57a972..35b71394 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/BookRepository.kt
@@ -43,6 +43,4 @@ interface BookRepository {
suspend fun getDefaultCover(
book: Book
): Result
-
- suspend fun getBooksWithRemoteId(): Result>
}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt
deleted file mode 100644
index e9f67284..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/ProgressRepository.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.repository
-
-import org.dueattendant149.bookshelf.domain.model.library.Book
-
-interface ProgressRepository {
- suspend fun syncReadingProgress(
- book: Book,
- lastChapter: String? = null,
- ): Result
-
- suspend fun syncPlaybackProgress(
- book: Book,
- currentFile: String,
- position: Long,
- duration: Long,
- ): Result
-
- suspend fun getBooksWithRemoteId(): Result>
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt
index 1dda4aa7..7c9b8f7d 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteLibraryRepository.kt
@@ -6,11 +6,6 @@
package org.dueattendant149.bookshelf.domain.repository
-import okhttp3.MultipartBody
-import okhttp3.RequestBody
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
@@ -18,8 +13,4 @@ interface RemoteLibraryRepository {
suspend fun getLibraries(): Result>
suspend fun getBooks(libraryId: String): Result>
suspend fun searchLibrary(libraryId: String, query: String): Result>
- suspend fun search(request: SearchRequest): Result
- suspend fun startDownload(request: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest): Result
- suspend fun listDownloads(): Result
- suspend fun uploadBook(file: MultipartBody.Part, bookType: RequestBody): Result
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt
deleted file mode 100644
index ba2ca7c1..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/RemoteTtsRepository.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.repository
-
-import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
-import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
-import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
-
-interface RemoteTtsRepository {
- suspend fun fetchEngines(): Result>
- suspend fun fetchVoices(engine: String? = null): Result>
- suspend fun createJob(
- bookId: String,
- engine: String,
- voiceId: String,
- speed: Double,
- ): Result
-
- suspend fun getJob(jobId: String): Result
- suspend fun downloadAudio(jobId: String): Result
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt
deleted file mode 100644
index fdd2f6d8..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/reader/PaginateReaderTextUseCase.kt
+++ /dev/null
@@ -1,380 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.reader
-
-import android.util.Log
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.text.TextLayoutResult
-import androidx.compose.ui.text.TextMeasurer
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.unit.Constraints
-import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
-import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
-import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
-import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class PaginateReaderTextUseCase @Inject constructor() {
-
- operator fun invoke(
- text: List,
- contentWidthPx: Int,
- contentHeightPx: Int,
- paragraphStyle: TextStyle,
- chapterTitleAlignment: ReaderTextAlignment,
- paragraphSpacingPx: Int,
- textMeasurer: TextMeasurer,
- imageMaxWidthPx: Int,
- chapterKeepLines: Int = 2,
- minWidowLines: Int = 2,
- minOrphanLines: Int = 2,
- safetyHeightPx: Int = 4,
- chapterExtraHeightPx: Int = 0,
- separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX,
- defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX,
- ): List {
- if (text.isEmpty() || contentWidthPx <= 0 || contentHeightPx <= 0) return emptyList()
-
- val availableHeight = (contentHeightPx - safetyHeightPx).coerceAtLeast(1)
- val workingBlocks = text.toMutableList()
- val metrics = workingBlocks.map { block ->
- measureBlock(
- block = block,
- contentWidthPx = contentWidthPx,
- imageMaxWidthPx = imageMaxWidthPx,
- paragraphStyle = paragraphStyle,
- chapterTitleAlignment = chapterTitleAlignment,
- textMeasurer = textMeasurer,
- chapterExtraHeightPx = chapterExtraHeightPx,
- separatorHeightPx = separatorHeightPx,
- defaultImageHeightPx = defaultImageHeightPx,
- )
- }.toMutableList()
-
- val pages = mutableListOf()
- val pageItems = mutableListOf()
- var startTextIndex = 0
- var remainingHeight = availableHeight
- var currentPageIndex = 0
- var index = 0
-
- /**
- * Returns the height of [metric] plus the inter-item spacing if this
- * block is not the first one on the current page. This matches the
- * [Arrangement.spacedBy] used in the paginated reader UI.
- */
- fun heightWithSpacing(metric: BlockMetrics): Int {
- return if (pageItems.isEmpty()) metric.height else metric.height + paragraphSpacingPx
- }
-
- fun emitPage(endTextIndex: Int) {
- if (pageItems.isEmpty()) return
- pages.add(
- ReaderPage(
- index = currentPageIndex,
- items = pageItems.toList(),
- startTextIndex = startTextIndex,
- endTextIndex = endTextIndex,
- )
- )
- currentPageIndex++
- pageItems.clear()
- remainingHeight = availableHeight
- startTextIndex = endTextIndex
- }
-
- while (index < workingBlocks.size) {
- val block = workingBlocks[index]
- val metric = metrics[index]
- val blockHeight = heightWithSpacing(metric)
-
- when (block) {
- is ReaderText.Chapter -> {
- val keep = followingTextKeepHeight(
- blocks = workingBlocks,
- metrics = metrics,
- startIndex = index + 1,
- keepLines = chapterKeepLines,
- )
- // Heading itself + spacing after it (if anything follows) + kept text.
- val requiredWithKeep = blockHeight +
- (if (index < workingBlocks.lastIndex) paragraphSpacingPx else 0) +
- keep
-
- when {
- requiredWithKeep > availableHeight -> {
- // Heading + kept lines don't fit on a fresh page.
- // Place heading alone.
- if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
- emitPage(index)
- }
- pageItems.add(block)
- remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
- index++
- }
-
- requiredWithKeep > remainingHeight && pageItems.isNotEmpty() -> {
- // Move heading to the next page so it stays with following text.
- emitPage(index)
- }
-
- else -> {
- pageItems.add(block)
- remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
- index++
- }
- }
- }
-
- is ReaderText.Separator,
- is ReaderText.Image -> {
- if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
- emitPage(index)
- }
- pageItems.add(block)
- remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
- index++
- }
-
- is ReaderText.Text -> {
- if (blockHeight <= remainingHeight) {
- pageItems.add(block)
- remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
- index++
- } else if (pageItems.isNotEmpty()) {
- emitPage(index)
- // Retry the same block on a fresh page.
- } else {
- // Fresh page and the paragraph still doesn't fit whole.
- val split = splitParagraph(
- block = block,
- metric = metric,
- availableHeight = remainingHeight,
- minWidowLines = minWidowLines,
- minOrphanLines = minOrphanLines,
- )
-
- if (split == null) {
- // Whole paragraph fits on a fresh page but we shouldn't be here.
- // Safety fallback: place it anyway.
- pageItems.add(block)
- remainingHeight = 0
- index++
- } else {
- val (head, tail) = split
- pageItems.add(ReaderText.Text(head))
- emitPage(index + 1)
- // The remainder is still the same logical block, so the next page
- // should start at this index rather than index + 1.
- startTextIndex = index
-
- if (tail.isBlank()) {
- index++
- } else {
- // Replace current block with remainder and retry on fresh page.
- val remainderBlock = ReaderText.Text(tail)
- workingBlocks[index] = remainderBlock
- metrics[index] = measureBlock(
- block = remainderBlock,
- contentWidthPx = contentWidthPx,
- imageMaxWidthPx = imageMaxWidthPx,
- paragraphStyle = paragraphStyle,
- chapterTitleAlignment = chapterTitleAlignment,
- textMeasurer = textMeasurer,
- chapterExtraHeightPx = chapterExtraHeightPx,
- separatorHeightPx = separatorHeightPx,
- defaultImageHeightPx = defaultImageHeightPx,
- )
- }
- }
- }
- }
- }
- }
-
- if (pageItems.isNotEmpty()) {
- emitPage(workingBlocks.size)
- }
-
- Log.d("Pagination", "Split ${text.size} blocks into ${pages.size} pages")
- return pages
- }
-
- private fun measureBlock(
- block: ReaderText,
- contentWidthPx: Int,
- imageMaxWidthPx: Int,
- paragraphStyle: TextStyle,
- chapterTitleAlignment: ReaderTextAlignment,
- textMeasurer: TextMeasurer,
- chapterExtraHeightPx: Int,
- separatorHeightPx: Int,
- defaultImageHeightPx: Int,
- ): BlockMetrics {
- return when (block) {
- is ReaderText.Text -> {
- val layout = measureText(block.line, paragraphStyle, contentWidthPx, textMeasurer)
- val textHeight = layout?.size?.height ?: 0
- val lineCount = layout?.lineCount ?: 0
- val lineHeights = (0 until lineCount).map { lineIndex ->
- layout!!.getLineBottom(lineIndex)
- }
- BlockMetrics(
- height = textHeight,
- lineHeights = lineHeights,
- isSplittable = true,
- layout = layout,
- )
- }
-
- is ReaderText.Chapter -> {
- val chapterStyle = readerChapterTextStyle(
- nested = block.nested,
- textAlignment = chapterTitleAlignment,
- )
- val layout = measureText(
- AnnotatedString(block.title),
- chapterStyle,
- contentWidthPx,
- textMeasurer,
- )
- val titleHeight = layout?.size?.height ?: 0
- BlockMetrics(
- height = titleHeight + chapterExtraHeightPx,
- isSplittable = false,
- )
- }
-
- is ReaderText.Separator -> BlockMetrics(
- height = separatorHeightPx,
- isSplittable = false,
- )
-
- is ReaderText.Image -> BlockMetrics(
- height = imageHeight(block, imageMaxWidthPx, defaultImageHeightPx),
- isSplittable = false,
- )
- }
- }
-
- /**
- * Returns the height of the first [keepLines] lines of the text block at
- * [startIndex], or the full height of the block if it has fewer lines. If
- * the next block is not splittable (e.g. an image), its full height is
- * returned so the heading can be moved to the next page when necessary.
- */
- private fun followingTextKeepHeight(
- blocks: List,
- metrics: List,
- startIndex: Int,
- keepLines: Int,
- ): Int {
- if (startIndex >= blocks.size) return 0
- val metric = metrics[startIndex]
- val block = blocks[startIndex]
-
- if (metric.lineHeights.isEmpty()) return metric.height
-
- if (block !is ReaderText.Text) {
- // Keep the whole non-text block with the heading.
- return metric.height
- }
-
- val linesToKeep = minOf(keepLines, metric.lineHeights.size)
- return metric.lineHeights[linesToKeep - 1].toInt()
- }
-
- private fun splitParagraph(
- block: ReaderText.Text,
- metric: BlockMetrics,
- availableHeight: Int,
- minWidowLines: Int,
- minOrphanLines: Int,
- ): Pair? {
- val layout = metric.layout ?: return null
- val lineCount = layout.lineCount
- if (lineCount == 0) return null
-
- // Find the last line whose bottom fits inside the available height.
- val lastFittingLine = layout.getLineForVerticalPosition(availableHeight.toFloat())
- .coerceIn(0, lineCount - 1)
-
- val maxLinesCurrent = lastFittingLine + 1
- val minLinesCurrent = minWidowLines.coerceAtLeast(1)
- val minLinesRemainder = minOrphanLines.coerceAtLeast(1)
-
- // Ideal break: current page has at least minWidowLines,
- // remainder has at least minOrphanLines.
- val idealMax = (lineCount - minLinesRemainder).coerceAtLeast(minLinesCurrent)
- val candidateLines = maxLinesCurrent.coerceIn(minLinesCurrent, idealMax)
-
- if (candidateLines in minLinesCurrent..idealMax) {
- val breakChar = layout.getLineEnd(candidateLines - 1, visibleEnd = true)
- if (breakChar in 1 until block.line.length) {
- return block.line.subSequence(0, breakChar) to
- block.line.subSequence(breakChar, block.line.length)
- }
- }
-
- // Paragraph is longer than a full page or constraints cannot be satisfied.
- // Split at the last line that actually fits.
- val forcedLines = maxLinesCurrent.coerceIn(1, lineCount - 1)
- val breakChar = layout.getLineEnd(forcedLines - 1, visibleEnd = true)
- if (breakChar in 1 until block.line.length) {
- return block.line.subSequence(0, breakChar) to
- block.line.subSequence(breakChar, block.line.length)
- }
-
- return null
- }
-
- private fun measureText(
- text: AnnotatedString,
- style: TextStyle,
- maxWidthPx: Int,
- textMeasurer: TextMeasurer,
- ): TextLayoutResult? {
- if (text.isBlank()) return null
- return try {
- textMeasurer.measure(
- text = text,
- style = style,
- constraints = Constraints(maxWidth = maxWidthPx),
- softWrap = true,
- )
- } catch (e: Exception) {
- Log.e("Pagination", "measure failed", e)
- null
- }
- }
-
- private fun imageHeight(
- block: ReaderText.Image,
- imageMaxWidthPx: Int,
- defaultImageHeightPx: Int,
- ): Int {
- val bitmap = block.imageBitmap
- val width = bitmap.width
- val height = bitmap.height
- if (width <= 0 || height <= 0) return defaultImageHeightPx
- return (imageMaxWidthPx * height / width).toInt()
- }
-
- private data class BlockMetrics(
- val height: Int,
- val lineHeights: List = emptyList(),
- val isSplittable: Boolean = false,
- val layout: TextLayoutResult? = null,
- )
-
- companion object {
- private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12
- private const val DEFAULT_IMAGE_HEIGHT_PX = 720
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt
deleted file mode 100644
index 371e3ddc..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/GlobalSearchUseCase.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.remote
-
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
-import javax.inject.Inject
-
-class GlobalSearchUseCase
- @Inject
- constructor(
- private val repository: RemoteLibraryRepository,
- ) {
- suspend operator fun invoke(request: SearchRequest): Result =
- repository.search(request)
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt
deleted file mode 100644
index f28f148d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/ListDownloadsUseCase.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.remote
-
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
-import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
-import javax.inject.Inject
-
-class ListDownloadsUseCase
- @Inject
- constructor(
- private val repository: RemoteLibraryRepository,
- ) {
- suspend operator fun invoke(): Result =
- repository.listDownloads()
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt
deleted file mode 100644
index d521511b..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/StartDownloadUseCase.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.remote
-
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
-import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
-import javax.inject.Inject
-
-class StartDownloadUseCase
- @Inject
- constructor(
- private val repository: RemoteLibraryRepository,
- ) {
- suspend operator fun invoke(request: DownloadRequest): Result =
- repository.startDownload(request)
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt
deleted file mode 100644
index b246974b..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncPlaybackProgressUseCase.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.remote
-
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
-import javax.inject.Inject
-
-class SyncPlaybackProgressUseCase
- @Inject
- constructor(
- private val progressRepository: ProgressRepository,
- ) {
- suspend operator fun invoke(
- book: Book,
- currentFile: String,
- position: Long,
- duration: Long,
- ): Result {
- if (book.remoteId.isBlank()) {
- return Result.success(Unit)
- }
- return progressRepository.syncPlaybackProgress(
- book = book,
- currentFile = currentFile,
- position = position,
- duration = duration,
- )
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt
deleted file mode 100644
index 6187815c..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/SyncReadingProgressUseCase.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.remote
-
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
-import javax.inject.Inject
-
-class SyncReadingProgressUseCase
- @Inject
- constructor(
- private val progressRepository: ProgressRepository,
- ) {
- suspend operator fun invoke(
- book: Book,
- lastChapter: String? = null,
- ): Result {
- if (book.remoteId.isBlank()) {
- return Result.success(Unit)
- }
- return progressRepository.syncReadingProgress(book, lastChapter)
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt
deleted file mode 100644
index eb639d39..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/UploadBookUseCase.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package org.dueattendant149.bookshelf.domain.use_case.remote
-
-import okhttp3.MediaType.Companion.toMediaTypeOrNull
-import okhttp3.MultipartBody
-import okhttp3.RequestBody.Companion.asRequestBody
-import okhttp3.RequestBody.Companion.toRequestBody
-import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
-import java.io.File
-import javax.inject.Inject
-
-class UploadBookUseCase
- @Inject
- constructor(
- private val repository: RemoteLibraryRepository,
- ) {
- suspend operator fun invoke(file: File, bookType: String): Result {
- val mediaType = file.name.let { name ->
- when {
- name.endsWith(".epub", ignoreCase = true) -> "application/epub+zip"
- name.endsWith(".fb2", ignoreCase = true) -> "application/fb2+xml"
- name.endsWith(".pdf", ignoreCase = true) -> "application/pdf"
- name.endsWith(".mobi", ignoreCase = true) -> "application/x-mobipocket-ebook"
- name.endsWith(".mp3", ignoreCase = true) -> "audio/mpeg"
- name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> "audio/mp4"
- name.endsWith(".zip", ignoreCase = true) -> "application/zip"
- else -> "application/octet-stream"
- }
- }
- val filePart = MultipartBody.Part.createFormData(
- "file",
- file.name,
- file.asRequestBody(mediaType.toMediaTypeOrNull()),
- )
- val typePart = bookType.toRequestBody("text/plain".toMediaTypeOrNull())
- return repository.uploadBook(filePart, typePart).map { response ->
- if (response.success) "Uploaded: ${response.filename}"
- else response.errorMessage ?: "Upload failed"
- }
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt
deleted file mode 100644
index 3eb81d46..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/CreateTtsJobUseCase.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.tts
-
-import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
-import javax.inject.Inject
-
-class CreateTtsJobUseCase
- @Inject
- constructor(
- private val repository: RemoteTtsRepository,
- ) {
- suspend operator fun invoke(
- bookId: String,
- engine: String,
- voiceId: String,
- speed: Double,
- ): Result = repository.createJob(bookId, engine, voiceId, speed)
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt
deleted file mode 100644
index 5b18f029..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsEnginesUseCase.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.tts
-
-import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
-import javax.inject.Inject
-
-class FetchTtsEnginesUseCase
- @Inject
- constructor(
- private val repository: RemoteTtsRepository,
- ) {
- suspend operator fun invoke(): Result> = repository.fetchEngines()
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt
deleted file mode 100644
index db319bb9..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/FetchTtsVoicesUseCase.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.tts
-
-import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
-import javax.inject.Inject
-
-class FetchTtsVoicesUseCase
- @Inject
- constructor(
- private val repository: RemoteTtsRepository,
- ) {
- suspend operator fun invoke(engine: String? = null): Result> =
- repository.fetchVoices(engine)
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt
deleted file mode 100644
index 3c80ed66..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/tts/GetTtsJobUseCase.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.domain.use_case.tts
-
-import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
-import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
-import javax.inject.Inject
-
-class GetTtsJobUseCase
- @Inject
- constructor(
- private val repository: RemoteTtsRepository,
- ) {
- suspend operator fun invoke(jobId: String): Result = repository.getJob(jobId)
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt
index 403a88f8..b81b0c9c 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt
@@ -20,19 +20,14 @@ fun String.isValidUri(): Boolean {
/**
* Returns true if the string starts with http:// or https:// (case-insensitive).
*/
-fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
-
-private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
+fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX)
/**
- * Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
+ * Adds https:// if the string has no http/https scheme.
*/
fun String.ensureUriScheme(): String {
val trimmed = trim()
- if (trimmed.hasUriScheme()) return trimmed
- val hostPart = trimmed.substringBefore("/")
- val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
- return "$scheme$trimmed"
+ return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
}
/**
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt
deleted file mode 100644
index 66efe568..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksModel.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.bookshelf
-
-import dagger.hilt.android.lifecycle.HiltViewModel
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
-import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
-import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
-import javax.inject.Inject
-
-@HiltViewModel
-class AudiobooksModel
- @Inject
- constructor(
- fetchLibrariesUseCase: FetchLibrariesUseCase,
- fetchBooksUseCase: FetchBooksUseCase,
- cacheBookUseCase: CacheBookUseCase,
- deleteCacheUseCase: DeleteCacheUseCase,
- getCacheStatusUseCase: GetCacheStatusUseCase,
- serverSettings: ServerSettings,
- serverStatusMonitor: ServerStatusMonitor,
- ) : RemoteLibraryViewModel(
- fetchLibrariesUseCase,
- fetchBooksUseCase,
- cacheBookUseCase,
- deleteCacheUseCase,
- getCacheStatusUseCase,
- serverSettings,
- serverStatusMonitor,
- ) {
- override val libraryNameFilter: String? = "audiobook"
- override val itemTypeFilter: String? = "audiobook"
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt
deleted file mode 100644
index 4c864971..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/AudiobooksScreen.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.bookshelf
-
-import android.os.Parcelable
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.hilt.navigation.compose.hiltViewModel
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
-import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
-import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
-import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
-import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-
-@Parcelize
-object AudiobooksScreen : Screen, Parcelable {
-
- @Composable
- override fun Content() {
- val model = hiltViewModel()
- val navigator = LocalNavigator.current
-
- LaunchedEffect(model.effects, navigator) {
- model.effects.collectLatest { effect ->
- when (effect) {
- is RemoteLibraryEffect.OnNavigateToReader -> {
- HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
- navigator.push(ReaderScreen(effect.bookId))
- }
- }
- }
- }
-
- RemoteLibraryContent(
- model = model,
- titleRes = R.string.audiobooks_screen,
- onPlayAudio = { book ->
- navigator.push(PlayerScreen(book))
- },
- navigateToTts = { bookRemoteId ->
- // Not used for audiobooks; secondary button opens chapters.
- }
- )
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt
deleted file mode 100644
index 9fa58b73..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksModel.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.bookshelf
-
-import dagger.hilt.android.lifecycle.HiltViewModel
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
-import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
-import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
-import javax.inject.Inject
-
-@HiltViewModel
-class BooksModel
- @Inject
- constructor(
- fetchLibrariesUseCase: FetchLibrariesUseCase,
- fetchBooksUseCase: FetchBooksUseCase,
- cacheBookUseCase: CacheBookUseCase,
- deleteCacheUseCase: DeleteCacheUseCase,
- getCacheStatusUseCase: GetCacheStatusUseCase,
- serverSettings: ServerSettings,
- serverStatusMonitor: ServerStatusMonitor,
- ) : RemoteLibraryViewModel(
- fetchLibrariesUseCase,
- fetchBooksUseCase,
- cacheBookUseCase,
- deleteCacheUseCase,
- getCacheStatusUseCase,
- serverSettings,
- serverStatusMonitor,
- ) {
- override val libraryNameFilter: String? = "book"
- override val itemTypeFilter: String? = "book"
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt
deleted file mode 100644
index df11ddf8..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/BooksScreen.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.bookshelf
-
-import android.os.Parcelable
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.hilt.navigation.compose.hiltViewModel
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
-import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
-import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
-import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
-import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-
-@Parcelize
-object BooksScreen : Screen, Parcelable {
-
- @Composable
- override fun Content() {
- val model = hiltViewModel()
- val navigator = LocalNavigator.current
-
- LaunchedEffect(model.effects, navigator) {
- model.effects.collectLatest { effect ->
- when (effect) {
- is RemoteLibraryEffect.OnNavigateToReader -> {
- HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
- navigator.push(ReaderScreen(effect.bookId))
- }
- }
- }
- }
-
- RemoteLibraryContent(
- model = model,
- titleRes = R.string.books_screen,
- onPlayAudio = { book ->
- navigator.push(PlayerScreen(book))
- },
- navigateToTts = { bookRemoteId ->
- navigator.push(TtsScreen(bookRemoteId))
- }
- )
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt
index a8608d87..e338c542 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt
@@ -6,9 +6,23 @@
package org.dueattendant149.bookshelf.presentation.bookshelf
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.settings.ServerSettings
+import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
+import org.dueattendant149.bookshelf.domain.model.library.Book
+import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
@@ -16,26 +30,145 @@ import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import javax.inject.Inject
+data class RemoteLibraryState(
+ val libraries: List = emptyList(),
+ val books: List = emptyList(),
+ val selectedLibrary: RemoteLibrary? = null,
+ val isLoading: Boolean = false,
+ val error: String? = null,
+ val cacheStatuses: Map = emptyMap(),
+ val offlineOnly: Boolean = false,
+ val cachingBookId: String? = null,
+)
+
@HiltViewModel
class RemoteLibraryModel
@Inject
constructor(
- fetchLibrariesUseCase: FetchLibrariesUseCase,
- fetchBooksUseCase: FetchBooksUseCase,
- cacheBookUseCase: CacheBookUseCase,
- deleteCacheUseCase: DeleteCacheUseCase,
- getCacheStatusUseCase: GetCacheStatusUseCase,
- serverSettings: ServerSettings,
- serverStatusMonitor: ServerStatusMonitor,
- ) : RemoteLibraryViewModel(
- fetchLibrariesUseCase,
- fetchBooksUseCase,
- cacheBookUseCase,
- deleteCacheUseCase,
- getCacheStatusUseCase,
- serverSettings,
- serverStatusMonitor,
- ) {
- override val libraryNameFilter: String? = null
- override val itemTypeFilter: String? = null
+ private val fetchLibrariesUseCase: FetchLibrariesUseCase,
+ private val fetchBooksUseCase: FetchBooksUseCase,
+ private val cacheBookUseCase: CacheBookUseCase,
+ private val deleteCacheUseCase: DeleteCacheUseCase,
+ private val getCacheStatusUseCase: GetCacheStatusUseCase,
+ private val serverSettings: ServerSettings,
+ ) : ViewModel() {
+ private val _state = MutableStateFlow(RemoteLibraryState())
+ val state = _state.asStateFlow()
+ private var cacheStatusJob: Job? = null
+
+ private val _effects = MutableSharedFlow()
+ val effects = _effects.asSharedFlow()
+
+ init {
+ loadLibraries()
+ }
+
+ fun loadLibraries() {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true, error = null) }
+ if (!serverSettings.hasCredentials()) {
+ _state.update { it.copy(isLoading = false, error = "Server not configured") }
+ return@launch
+ }
+ val result = fetchLibrariesUseCase()
+ _state.update {
+ it.copy(
+ isLoading = false,
+ libraries = result.getOrDefault(emptyList()),
+ error = result.exceptionOrNull()?.message,
+ )
+ }
+ }
+ }
+
+ fun selectLibrary(library: RemoteLibrary) {
+ _state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
+ loadBooks(library.id)
+ }
+
+ fun loadBooks(libraryId: String) {
+ viewModelScope.launch {
+ _state.update { it.copy(isLoading = true, error = null) }
+ val result = fetchBooksUseCase(libraryId)
+ _state.update {
+ it.copy(
+ isLoading = false,
+ books = result.getOrDefault(emptyList()),
+ error = result.exceptionOrNull()?.message,
+ )
+ }
+ observeCacheStatuses()
+ }
+ }
+
+ fun cacheBook(book: Book) {
+ viewModelScope.launch {
+ _state.update { it.copy(cachingBookId = book.remoteId) }
+ val result = cacheBookUseCase(book)
+ _state.update { it.copy(cachingBookId = null) }
+ if (result.isFailure) {
+ _state.update {
+ it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
+ }
+ }
+ observeCacheStatuses()
+ }
+ }
+
+ fun deleteCache(book: Book) {
+ viewModelScope.launch {
+ val result = deleteCacheUseCase(book.remoteId)
+ if (result.isFailure) {
+ _state.update {
+ it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
+ }
+ }
+ }
+ }
+
+ fun toggleOfflineOnly() {
+ _state.update { it.copy(offlineOnly = !it.offlineOnly) }
+ }
+
+ fun openBook(book: Book) {
+ if (!book.hasEbook || book.remoteId.isBlank()) return
+
+ viewModelScope.launch {
+ _state.update { it.copy(cachingBookId = book.remoteId) }
+ val result = withContext(Dispatchers.IO) {
+ cacheBookUseCase(book)
+ }
+ _state.update { it.copy(cachingBookId = null) }
+
+ result
+ .onSuccess { bookId ->
+ _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
+ }
+ .onFailure { error ->
+ _state.update { it.copy(error = error.message ?: "Could not open book") }
+ }
+ }
+ }
+
+ private fun observeCacheStatuses() {
+ cacheStatusJob?.cancel()
+ val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
+ if (remoteIds.isEmpty()) {
+ _state.update { it.copy(cacheStatuses = emptyMap()) }
+ return
+ }
+
+ cacheStatusJob =
+ viewModelScope.launch {
+ combine(
+ remoteIds.map { remoteId ->
+ getCacheStatusUseCase(remoteId)
+ },
+ ) { statuses ->
+ statuses.toList().associateBy { it.remoteId }
+ }.collect { statuses ->
+ _state.update { it.copy(cacheStatuses = statuses) }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt
index c0002b32..c5aedec1 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryScreen.kt
@@ -12,12 +12,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
-import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@@ -40,15 +37,6 @@ object RemoteLibraryScreen : Screen, Parcelable {
}
}
- RemoteLibraryContent(
- model = model,
- titleRes = R.string.bookshelf_screen,
- onPlayAudio = { book ->
- navigator.push(PlayerScreen(book))
- },
- navigateToTts = { bookRemoteId ->
- navigator.push(TtsScreen(bookRemoteId))
- }
- )
+ RemoteLibraryContent(model = model)
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt
deleted file mode 100644
index c3d73b7a..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryViewModel.kt
+++ /dev/null
@@ -1,265 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.bookshelf
-
-import android.util.Log
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
-import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
-import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
-import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
-
-/**
- * Shared UI state for remote-library tabs.
- */
-data class RemoteLibraryState(
- val libraries: List = emptyList(),
- val books: List = emptyList(),
- val selectedLibrary: RemoteLibrary? = null,
- val isLoading: Boolean = false,
- val error: String? = null,
- val cacheStatuses: Map = emptyMap(),
- val offlineOnly: Boolean = false,
- val cachingBookId: String? = null,
- val serverOnline: Boolean = true,
-)
-
-/**
- * Base ViewModel for tabs that display remote libraries filtered by media type.
- *
- * @param mediaTypeFilter null means no filtering (legacy "bookshelf" view).
- */
-abstract class RemoteLibraryViewModel(
- private val fetchLibrariesUseCase: FetchLibrariesUseCase,
- private val fetchBooksUseCase: FetchBooksUseCase,
- private val cacheBookUseCase: CacheBookUseCase,
- private val deleteCacheUseCase: DeleteCacheUseCase,
- private val getCacheStatusUseCase: GetCacheStatusUseCase,
- private val serverSettings: ServerSettings,
- private val serverStatusMonitor: ServerStatusMonitor,
-) : ViewModel() {
-
- /**
- * Used to pick the default library by name when the backend has multiple libraries.
- */
- protected abstract val libraryNameFilter: String?
-
- /**
- * Used to split the unified item list into Books vs Audiobooks tabs.
- */
- protected abstract val itemTypeFilter: String?
-
- private val _state = MutableStateFlow(RemoteLibraryState())
- val state = _state.asStateFlow()
- private var cacheStatusJob: Job? = null
-
- private val _effects = MutableSharedFlow()
- val effects = _effects.asSharedFlow()
-
- init {
- loadLibraries()
- viewModelScope.launch {
- serverStatusMonitor.isOnline.collect { online ->
- _state.update {
- if (it.serverOnline == online) it
- else it.copy(serverOnline = online, offlineOnly = !online || it.offlineOnly)
- }
- }
- }
- }
-
- fun loadLibraries() {
- viewModelScope.launch {
- _state.update { it.copy(isLoading = true, error = null) }
- if (!serverSettings.hasCredentials()) {
- _state.update { it.copy(isLoading = false, error = "Server not configured") }
- return@launch
- }
- val result = fetchLibrariesUseCase()
- val allLibraries = result.getOrDefault(emptyList())
- val libraries = allLibraries.filter { matchesLibraryFilter(it) }
-
- _state.update {
- it.copy(
- isLoading = false,
- libraries = libraries,
- error = result.exceptionOrNull()?.message,
- )
- }
-
- if (libraries.isNotEmpty() && state.value.selectedLibrary == null) {
- selectLibrary(libraries.first())
- }
- }
- }
-
- /**
- * Matches library to filter by name pattern (e.g. "Books" vs "Audiobooks").
- * The unified endpoint returns mixed items, so we no longer filter libraries by mediaType.
- */
- private fun matchesLibraryFilter(library: RemoteLibrary): Boolean {
- val filter = libraryNameFilter ?: return true
- val name = library.name.lowercase()
- return when (filter) {
- "book" -> name.contains("book") && !name.contains("audio")
- "audiobook" -> name.contains("audio")
- else -> true
- }
- }
-
- fun selectLibrary(library: RemoteLibrary) {
- _state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
- loadBooks(library.id)
- }
-
- fun loadBooks(libraryId: String) {
- viewModelScope.launch {
- _state.update { it.copy(isLoading = true, error = null) }
- val result = fetchBooksUseCase(libraryId)
- val allBooks = result.getOrDefault(emptyList())
- val books = if (itemTypeFilter != null) {
- allBooks.filter { matchesItemTypeFilter(it) }
- } else {
- allBooks
- }
-
- _state.update {
- it.copy(
- isLoading = false,
- books = books,
- error = result.exceptionOrNull()?.message,
- )
- }
- observeCacheStatuses()
-
- if (state.value.serverOnline) {
- autoCacheEbooks(books)
- }
- }
- }
-
- private fun autoCacheEbooks(books: List) {
- // Auto-cache on tab open is disabled by design. Caching now happens
- // only when the user explicitly opens a book or presses download.
- }
-
- private fun matchesItemTypeFilter(book: Book): Boolean {
- val filter = itemTypeFilter ?: return true
- return when (filter) {
- "book" -> book.itemType == "ebook" || book.itemType == "hybrid"
- "audiobook" -> book.itemType == "audiobook" || book.itemType == "podcast" || book.itemType == "hybrid"
- else -> true
- }
- }
-
- fun cacheBook(book: Book) {
- viewModelScope.launch {
- _state.update { it.copy(cachingBookId = book.remoteId) }
- val result = cacheBookUseCase(book)
- _state.update { it.copy(cachingBookId = null) }
- if (result.isFailure) {
- _state.update {
- it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
- }
- }
- observeCacheStatuses()
- }
- }
-
- fun deleteCache(book: Book) {
- viewModelScope.launch {
- val result = deleteCacheUseCase(book.remoteId)
- if (result.isFailure) {
- _state.update {
- it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
- }
- }
- }
- }
-
- fun toggleOfflineOnly() {
- _state.update { it.copy(offlineOnly = !it.offlineOnly) }
- }
-
- fun openBook(book: Book) {
- if (!book.hasEbook || book.remoteId.isBlank()) return
-
- viewModelScope.launch {
- _state.update { it.copy(cachingBookId = book.remoteId) }
- val result = cacheBookUseCase(book)
- _state.update { it.copy(cachingBookId = null) }
-
- result
- .onSuccess { bookId ->
- if (bookId <= 0) {
- _state.update { it.copy(error = "Could not open book: invalid id") }
- return@onSuccess
- }
- _effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
- }
- .onFailure { error ->
- _state.update { it.copy(error = error.message ?: "Could not open book") }
- }
- }
- }
-
- /**
- * Starts caching an audiobook so it can be played offline.
- */
- fun cacheAudiobook(book: Book) {
- if (!book.hasAudio || book.remoteId.isBlank()) return
-
- viewModelScope.launch {
- _state.update { it.copy(cachingBookId = book.remoteId) }
- val result = cacheBookUseCase(book)
- _state.update { it.copy(cachingBookId = null) }
- if (result.isFailure) {
- _state.update {
- it.copy(error = result.exceptionOrNull()?.message ?: "Could not cache audiobook")
- }
- }
- observeCacheStatuses()
- }
- }
-
- private fun observeCacheStatuses() {
- cacheStatusJob?.cancel()
- val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
- if (remoteIds.isEmpty()) {
- _state.update { it.copy(cacheStatuses = emptyMap()) }
- return
- }
-
- cacheStatusJob =
- viewModelScope.launch {
- combine(
- remoteIds.map { remoteId ->
- getCacheStatusUseCase(remoteId)
- },
- ) { statuses ->
- statuses.toList().associateBy { it.remoteId }
- }.collect { statuses ->
- _state.update { it.copy(cacheStatuses = statuses) }
- }
- }
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt
index 37d74ba5..0ca1a721 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/main/MainActivity.kt
@@ -24,10 +24,13 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentListOf
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.data.settings.SettingsManager
-import org.dueattendant149.bookshelf.presentation.bookshelf.BooksScreen
-import org.dueattendant149.bookshelf.presentation.bookshelf.AudiobooksScreen
-import org.dueattendant149.bookshelf.presentation.search.SearchScreen
-import org.dueattendant149.bookshelf.presentation.settings.SettingsScreen
+import org.dueattendant149.bookshelf.presentation.browse.BrowseModel
+import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen
+import org.dueattendant149.bookshelf.presentation.history.HistoryModel
+import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
+import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryScreen
+import org.dueattendant149.bookshelf.presentation.library.LibraryModel
+import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem
import org.dueattendant149.bookshelf.presentation.navigator.StackEvent
import org.dueattendant149.bookshelf.presentation.settings.SettingsModel
@@ -74,6 +77,11 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
+ // Initializing Screen Models
+ val libraryModel = hiltViewModel()
+ val historyModel = hiltViewModel()
+ val browseModel = hiltViewModel()
+
SettingsEffects(
effects = settingsModel.effects
)
@@ -81,32 +89,32 @@ class MainActivity : AppCompatActivity() {
ProvideSettings(settings) {
val tabs = persistentListOf(
NavigatorItem(
- screen = BooksScreen,
- title = R.string.books_screen,
- tooltip = R.string.books_content_desc,
- selectedIcon = R.drawable.book_tab_filled,
- unselectedIcon = R.drawable.book_tab_outlined
+ screen = LibraryScreen,
+ title = R.string.library_screen,
+ tooltip = R.string.library_content_desc,
+ selectedIcon = R.drawable.library_screen_filled,
+ unselectedIcon = R.drawable.library_screen_outlined
),
NavigatorItem(
- screen = AudiobooksScreen,
- title = R.string.audiobooks_screen,
- tooltip = R.string.audiobooks_content_desc,
- selectedIcon = R.drawable.audiobook_tab_filled,
- unselectedIcon = R.drawable.audiobook_tab_outlined
+ screen = HistoryScreen,
+ title = R.string.history_screen,
+ tooltip = R.string.history_content_desc,
+ selectedIcon = R.drawable.history_screen_filled,
+ unselectedIcon = R.drawable.history_screen_outlined
),
NavigatorItem(
- screen = SearchScreen,
- title = R.string.search_screen,
- tooltip = R.string.search_content_desc_network,
- selectedIcon = R.drawable.search_tab_filled,
- unselectedIcon = R.drawable.search_tab_outlined
+ screen = BrowseScreen,
+ title = R.string.browse_screen,
+ tooltip = R.string.browse_content_desc,
+ selectedIcon = R.drawable.browse_screen_filled,
+ unselectedIcon = R.drawable.browse_screen_outlined
),
NavigatorItem(
- screen = SettingsScreen,
- title = R.string.settings_screen,
- tooltip = R.string.settings_screen,
- selectedIcon = R.drawable.settings_tab_filled,
- unselectedIcon = R.drawable.settings_tab_outlined
+ screen = RemoteLibraryScreen,
+ title = R.string.bookshelf_screen,
+ tooltip = R.string.bookshelf_content_desc,
+ selectedIcon = R.drawable.bookshelf_screen_filled,
+ unselectedIcon = R.drawable.bookshelf_screen_outlined
)
)
@@ -121,7 +129,7 @@ class MainActivity : AppCompatActivity() {
) {
Navigator(
initialScreen = if (settings.showStartScreen.value) StartScreen
- else BooksScreen,
+ else LibraryScreen,
transitionSpec = { lastEvent ->
when (lastEvent) {
StackEvent.DEFAULT -> {
@@ -137,14 +145,14 @@ class MainActivity : AppCompatActivity() {
},
contentKey = {
when (it) {
- BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs"
+ LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs"
else -> it
}
},
backHandlerEnabled = { it != StartScreen }
- ) { screen ->
+ ) { screen ->
when (screen) {
- BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> {
+ LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> {
NavigatorTabs(
currentTab = screen,
transitionSpec = {
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt
index 57f5ce70..8ec90827 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/navigator/Navigator.kt
@@ -52,8 +52,7 @@ class Navigator @AssistedInject constructor(
popping: Boolean = false,
saveInBackStack: Boolean = true
) {
- val current = lastItem.value
- if (current::class == targetScreen::class && current == targetScreen) return
+ if (lastItem.value::class == targetScreen::class) return
if (!saveInBackStack) items.removeLast()
changeStackEvent(
@@ -61,6 +60,7 @@ class Navigator @AssistedInject constructor(
else StackEvent.DEFAULT
)
+ if (lastItem.value::class == targetScreen::class) items.removeLast()
items.add(targetScreen)
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt
deleted file mode 100644
index e95be71e..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/ChapterListScreen.kt
+++ /dev/null
@@ -1,104 +0,0 @@
-package org.dueattendant149.bookshelf.presentation.player
-
-import android.os.Parcelable
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.Card
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-
-@Parcelize
-data class ChapterListScreen(val book: Book) : Screen, Parcelable {
-
- @OptIn(ExperimentalMaterial3Api::class)
- @Composable
- override fun Content() {
- val model = hiltViewModel()
- val state = model.state.collectAsStateWithLifecycle().value
- val navigator = LocalNavigator.current
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(book.title) },
- navigationIcon = {
- IconButton(onClick = { navigator.pop() }) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = stringResource(R.string.go_back_content_desc)
- )
- }
- }
- )
- }
- ) { padding ->
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(16.dp),
- verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)
- ) {
- itemsIndexed(state.tracks) { index, track ->
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- navigator.push(PlayerScreen(book, startTrackIndex = index))
- }
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Text(
- text = "${index + 1}. ${track.title}",
- style = MaterialTheme.typography.titleMedium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- text = formatMs(track.durationMs),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
- }
- }
- }
- }
-}
-
-private fun formatMs(ms: Long): String {
- val totalSeconds = ms / 1000
- val hours = totalSeconds / 3600
- val minutes = (totalSeconds % 3600) / 60
- val seconds = totalSeconds % 60
- return if (hours > 0) {
- String.format("%d:%02d:%02d", hours, minutes, seconds)
- } else {
- String.format("%02d:%02d", minutes, seconds)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt
deleted file mode 100644
index 6dbc3f18..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerModel.kt
+++ /dev/null
@@ -1,349 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.player
-
-import android.content.Context
-import android.content.Intent
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import androidx.media3.common.MediaItem
-import androidx.media3.common.PlaybackException
-import androidx.media3.common.Player
-import androidx.media3.common.util.UnstableApi
-import androidx.media3.datasource.cache.CacheDataSource
-import androidx.media3.datasource.cache.SimpleCache
-import androidx.media3.datasource.okhttp.OkHttpDataSource
-import androidx.media3.exoplayer.DefaultRenderersFactory
-import androidx.media3.exoplayer.ExoPlayer
-import androidx.media3.exoplayer.LoadControl
-import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
-import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
-import androidx.media3.exoplayer.upstream.DefaultAllocator
-import dagger.hilt.android.lifecycle.HiltViewModel
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.dueattendant149.bookshelf.data.local.room.BookDatabase
-import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
-import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
-import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
-import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
-import org.dueattendant149.bookshelf.data.settings.ServerSettings
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
-import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
-import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
-import org.dueattendant149.bookshelf.domain.util.fixUriScheme
-import java.io.File
-import javax.inject.Inject
-
-@HiltViewModel
-class PlayerModel
- @Inject
- constructor(
- @ApplicationContext private val context: Context,
- private val serverSettings: ServerSettings,
- private val audiobookshelfRepository: AudiobookshelfRepository,
- private val database: BookDatabase,
- private val bookMapper: BookMapper,
- private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
- private val cacheBookUseCase: CacheBookUseCase,
- private val okHttpClient: okhttp3.OkHttpClient,
- private val authProvider: PlaybackAuthProvider,
- private val simpleCache: SimpleCache,
- ) : ViewModel() {
-
- @OptIn(UnstableApi::class)
- val exoPlayer: ExoPlayer by lazy {
- val authClient = okHttpClient.newBuilder()
- .addInterceptor(AuthorizationInterceptor(authProvider::token))
- .build()
- val upstreamFactory = OkHttpDataSource.Factory(authClient)
- val cacheDataSourceFactory = CacheDataSource.Factory()
- .setCache(simpleCache)
- .setUpstreamDataSourceFactory(upstreamFactory)
- .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
- ExoPlayer.Builder(context)
- .setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true))
- .setTrackSelector(DefaultTrackSelector(context))
- .setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
- .setLoadControl(createLoadControl())
- .build()
- .also { it.addListener(listener) }
- }
-
- private fun createLoadControl(): LoadControl {
- return androidx.media3.exoplayer.DefaultLoadControl.Builder()
- .setAllocator(DefaultAllocator(true, 16 * 1024))
- .setBufferDurationsMs(
- /* minBufferMs = */ 30_000,
- /* maxBufferMs = */ 120_000,
- /* bufferForPlaybackMs = */ 2_500,
- /* bufferForPlaybackAfterRebufferMs = */ 5_000,
- )
- .setPrioritizeTimeOverSizeThresholds(true)
- .build()
- }
-
- private val _state = MutableStateFlow(PlayerState())
- val state = _state.asStateFlow()
-
- private val listener =
- object : Player.Listener {
- override fun onIsPlayingChanged(isPlaying: Boolean) {
- _state.update { it.copy(isPlaying = isPlaying) }
- }
-
- override fun onPlaybackStateChanged(playbackState: Int) {
- _state.update {
- it.copy(
- isLoading = playbackState == Player.STATE_BUFFERING,
- durationMs = exoPlayer.duration.coerceAtLeast(0L),
- )
- }
- }
-
- override fun onPositionDiscontinuity(
- oldPosition: Player.PositionInfo,
- newPosition: Player.PositionInfo,
- reason: Int,
- ) {
- _state.update {
- it.copy(
- currentTrackIndex = exoPlayer.currentMediaItemIndex,
- currentPositionMs = newPosition.positionMs.coerceAtLeast(0L),
- durationMs = exoPlayer.duration.coerceAtLeast(0L),
- )
- }
- }
-
- override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
- _state.update {
- it.copy(
- currentTrackIndex = exoPlayer.currentMediaItemIndex,
- durationMs = exoPlayer.duration.coerceAtLeast(0L),
- currentPositionMs = 0L,
- )
- }
- }
-
- override fun onPlayerError(error: PlaybackException) {
- _state.update { it.copy(error = error.localizedMessage, isLoading = false) }
- }
- }
-
- init {
- startProgressUpdates()
- }
-
- fun init(book: Book, startTrackIndex: Int = 0) {
- if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank() && startTrackIndex == 0) return
- _state.update { it.copy(book = book, isLoading = true, error = null) }
-
- viewModelScope.launch {
- authProvider.refresh(serverSettings)
-
- val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme()
- if (baseUrl.isNullOrBlank()) {
- _state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") }
- return@launch
- }
-
- val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
- tracksResult.fold(
- onSuccess = { tracks ->
- if (tracks.isEmpty()) {
- _state.update { it.copy(isLoading = false, error = "No audio files found") }
- return@fold
- }
- _state.update { it.copy(tracks = tracks) }
- val startIndex = startTrackIndex.coerceIn(0, tracks.lastIndex)
- val startPosition = if (startIndex == 0) book.audioCurrentPosition else 0L
- loadTracks(baseUrl, tracks, startIndex, startPosition)
- startService(book)
- },
- onFailure = { error ->
- _state.update { it.copy(isLoading = false, error = error.message) }
- }
- )
- }
- }
-
- private suspend fun loadTracks(baseUrl: String, tracks: List, startIndex: Int = 0, startPositionMs: Long = 0L) {
- val normalizedBase = baseUrl.trimEnd('/') + "/"
- val bookId = database.bookDao.findBookByRemoteId(_state.value.book.remoteId)?.id
- val cachedAudioFiles = bookId?.let { id ->
- withContext(Dispatchers.IO) { database.audioFileDao.getByBookId(id) }
- }.orEmpty()
-
- val mediaItems = tracks.map { track ->
- val localFile = cachedAudioFiles
- .firstOrNull { it.fileId == track.fileId }
- ?.localPath
- ?.let { path -> File(path).takeIf { it.exists() } }
- val uri = localFile?.toURI()?.toString()
- ?: "${normalizedBase}api/v1/books/${_state.value.book.remoteId}/file/${track.fileId}"
- MediaItem.Builder()
- .setMediaId(track.fileId)
- .setUri(uri)
- .setMediaMetadata(
- androidx.media3.common.MediaMetadata.Builder()
- .setTitle(track.title)
- .setAlbumTitle(_state.value.book.title)
- .setArtist(_state.value.book.author.getAsString())
- .build()
- )
- .build()
- }
-
- exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
- exoPlayer.prepare()
- exoPlayer.play()
- }
-
- private var lastSyncTimeMs: Long = 0L
- private var lastSavedPositionMs: Long = 0L
-
- private fun startProgressUpdates() {
- viewModelScope.launch {
- while (isActive) {
- if (exoPlayer.isPlaying || exoPlayer.isLoading) {
- val pos = exoPlayer.currentPosition.coerceAtLeast(0L)
- val dur = exoPlayer.duration.coerceAtLeast(0L)
- val trackIndex = exoPlayer.currentMediaItemIndex
- _state.update {
- it.copy(
- currentTrackIndex = trackIndex,
- currentPositionMs = pos,
- bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L),
- durationMs = dur,
- )
- }
-
- val now = System.currentTimeMillis()
- if (now - lastSyncTimeMs >= 30_000L && pos != lastSavedPositionMs) {
- lastSyncTimeMs = now
- lastSavedPositionMs = pos
- persistPlaybackProgress(pos, dur)
- }
- }
- delay(500)
- }
- }
- }
-
- private fun persistPlaybackProgress(positionMs: Long, durationMs: Long) {
- val book = _state.value.book
- if (book.remoteId.isBlank()) return
- val currentFile = _state.value.tracks.getOrNull(exoPlayer.currentMediaItemIndex)?.fileId
- ?: book.audioCurrentFile
-
- viewModelScope.launch {
- val updated = book.copy(
- audioCurrentFile = currentFile,
- audioCurrentPosition = positionMs,
- audioDuration = if (durationMs > 0) durationMs else book.audioDuration,
- )
- database.bookDao.updateBook(bookMapper.toBookEntity(updated))
-
- syncPlaybackProgressUseCase(
- book = updated,
- currentFile = currentFile,
- position = positionMs,
- duration = updated.audioDuration,
- )
- }
- }
-
- private fun startService(book: Book) {
- val intent = Intent(context, AudioPlaybackService::class.java).apply {
- putExtra(AudioPlaybackService.EXTRA_BOOK, book)
- }
- context.startForegroundService(intent)
- }
-
- fun playPause() {
- if (exoPlayer.isPlaying) {
- exoPlayer.pause()
- } else {
- exoPlayer.play()
- }
- }
-
- fun seekTo(positionMs: Long) {
- exoPlayer.seekTo(positionMs.coerceIn(0L, exoPlayer.duration.coerceAtLeast(0L)))
- }
-
- fun seekForward(ms: Long = 30_000L) {
- seekTo(exoPlayer.currentPosition + ms)
- }
-
- fun seekBackward(ms: Long = 10_000L) {
- seekTo(exoPlayer.currentPosition - ms)
- }
-
- fun skipToNext() {
- if (exoPlayer.hasNextMediaItem()) {
- exoPlayer.seekToNextMediaItem()
- }
- }
-
- fun skipToPrevious() {
- if (exoPlayer.hasPreviousMediaItem()) {
- exoPlayer.seekToPreviousMediaItem()
- }
- }
-
- fun setSpeed(speed: Float) {
- val clamped = speed.coerceIn(0.5f, 2.0f)
- exoPlayer.setPlaybackSpeed(clamped)
- _state.update { it.copy(playbackSpeed = clamped) }
- }
-
- fun selectTrack(index: Int) {
- if (index in 0 until exoPlayer.mediaItemCount) {
- exoPlayer.seekToDefaultPosition(index)
- }
- }
-
- fun downloadAudiobook() {
- val book = _state.value.book
- if (book.remoteId.isBlank() || !book.hasAudio) return
- _state.update { it.copy(isCaching = true, cacheProgress = 0f) }
-
- viewModelScope.launch {
- val result = cacheBookUseCase(book)
- result.onSuccess {
- // Refresh player with local files now that caching is done.
- val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme() ?: return@onSuccess
- val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
- tracksResult.onSuccess { tracks ->
- _state.update { it.copy(tracks = tracks) }
- loadTracks(baseUrl, tracks, exoPlayer.currentMediaItemIndex, exoPlayer.currentPosition)
- }
- }.onFailure { error ->
- _state.update { it.copy(error = error.message, isCaching = false) }
- }
- _state.update { it.copy(isCaching = false, cacheProgress = 1f) }
- }
- }
-
- override fun onCleared() {
- exoPlayer.removeListener(listener)
- exoPlayer.stop()
- exoPlayer.clearMediaItems()
- context.stopService(Intent(context, AudioPlaybackService::class.java))
- super.onCleared()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt
deleted file mode 100644
index 51380c92..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerScreen.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.player
-
-import android.os.Parcelable
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.hilt.navigation.compose.hiltViewModel
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.ui.player.PlayerContent
-
-@Parcelize
-data class PlayerScreen(
- val book: Book,
- val startTrackIndex: Int = 0,
-) : Screen, Parcelable {
-
- @Composable
- override fun Content() {
- val model = hiltViewModel()
-
- LaunchedEffect(book, startTrackIndex) {
- model.init(book, startTrackIndex)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- // ExoPlayer lifecycle is tied to the ViewModel; no extra cleanup needed here.
- }
- }
-
- val navigator = org.dueattendant149.bookshelf.ui.navigator.LocalNavigator.current
- PlayerContent(
- state = model.state,
- onPlayPause = model::playPause,
- onSeek = model::seekTo,
- onSeekForward = model::seekForward,
- onSeekBackward = model::seekBackward,
- onSkipNext = model::skipToNext,
- onSkipPrevious = model::skipToPrevious,
- onSetSpeed = model::setSpeed,
- onSelectTrack = model::selectTrack,
- onOpenChapters = { navigator.push(ChapterListScreen(book)) },
- onDownloadAudiobook = model::downloadAudiobook,
- )
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt
deleted file mode 100644
index c225fd50..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/player/PlayerState.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package org.dueattendant149.bookshelf.presentation.player
-
-import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
-
-data class PlayerState(
- val book: Book = Book.default,
- val tracks: List = emptyList(),
- val currentTrackIndex: Int = 0,
- val isPlaying: Boolean = false,
- val isLoading: Boolean = false,
- val isCaching: Boolean = false,
- val cacheProgress: Float = 0f,
- val currentPositionMs: Long = 0L,
- val durationMs: Long = 0L,
- val bufferedPositionMs: Long = 0L,
- val playbackSpeed: Float = 1f,
- val error: String? = null,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt
index 98c36c6c..229d26f9 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEffect.kt
@@ -38,8 +38,4 @@ sealed class ReaderEffect {
data class OnNavigateToBookInfo(
val changePath: Boolean
) : ReaderEffect()
-
- data class OnNavigateToRsvp(
- val bookId: Int
- ) : ReaderEffect()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt
index 6bb8b711..04e16265 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderEvent.kt
@@ -40,14 +40,6 @@ sealed class ReaderEvent {
val progress: Float
) : ReaderEvent()
- data class OnChangePage(
- val page: Int
- ) : ReaderEvent()
-
- data class OnPagesComputed(
- val pages: List
- ) : ReaderEvent()
-
data class OnRestoreCheckpoint(
val checkpoint: Checkpoint
) : ReaderEvent()
@@ -86,8 +78,4 @@ sealed class ReaderEvent {
data class OnNavigateToBookInfo(
val changePath: Boolean
) : ReaderEvent()
-
- data class OnNavigateToRsvp(
- val bookId: Int
- ) : ReaderEvent()
}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt
index e6436ea5..c045f8bd 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderModel.kt
@@ -37,8 +37,6 @@ import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseC
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
-import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
@@ -52,9 +50,7 @@ class ReaderModel @Inject constructor(
private val getTextUseCase: GetTextUseCase,
private val getBookUseCase: GetBookUseCase,
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
- private val getChapterProgressUseCase: GetChapterProgressUseCase,
- private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
- private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
+ private val getChapterProgressUseCase: GetChapterProgressUseCase
) : ViewModel() {
private val mutex = Mutex()
@@ -98,9 +94,7 @@ class ReaderModel @Inject constructor(
book = it.book.copy(
lastOpened = lastOpened
),
- text = text,
- pages = emptyList(),
- currentPage = 0
+ text = text
)
}
ensureActive()
@@ -115,34 +109,24 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnRestoreScroll -> {
- val state = _state.value
- if (state.pages.isNotEmpty()) {
- val page = state.pages
- .indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
- .takeIf { it >= 0 }
- ?: 0
- onEvent(ReaderEvent.OnChangePage(page))
- _state.update { it.copy(isLoading = false, errorMessage = null) }
- } else {
- snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 }
+ snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 }
- state.listState.requestScrollToItem(
- index = state.book.scrollIndex,
- scrollOffset = state.book.scrollOffset
+ _state.value.listState.requestScrollToItem(
+ index = _state.value.book.scrollIndex,
+ scrollOffset = _state.value.book.scrollOffset
+ )
+
+ _state.update {
+ val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
+ it.book.scrollIndex,
+ it.text
+ )
+ it.copy(
+ currentChapter = currentChapter,
+ currentChapterProgress = currentChapterProgress,
+ isLoading = false,
+ errorMessage = null
)
-
- _state.update {
- val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
- it.book.scrollIndex,
- it.text
- )
- it.copy(
- currentChapter = currentChapter,
- currentChapterProgress = currentChapterProgress,
- isLoading = false,
- errorMessage = null
- )
- }
}
}
@@ -193,10 +177,6 @@ class ReaderModel @Inject constructor(
}
updateBookUseCase(_state.value.book)
- syncReadingProgressUseCase(
- _state.value.book,
- _state.value.currentChapter?.title,
- )
LibraryScreen.refreshListChannel.trySend(300)
HistoryScreen.refreshListChannel.trySend(300)
@@ -244,69 +224,14 @@ class ReaderModel @Inject constructor(
scrollJob = viewModelScope.launch(Dispatchers.IO) {
delay(300)
- val state = _state.value
- if (state.pages.isNotEmpty()) {
- val targetPage = (event.progress * state.pages.lastIndex).roundToInt()
- onEvent(ReaderEvent.OnChangePage(targetPage))
- } else {
- val scrollTo = (state.text.lastIndex * event.progress).roundToInt()
- state.listState.requestScrollToItem(
- index = scrollTo,
- scrollOffset = 0
- )
- onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
- }
- }
- }
+ val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
- is ReaderEvent.OnChangePage -> {
- withContext(Dispatchers.Default) {
- val state = _state.value
- val pages = state.pages
- if (pages.isEmpty()) return@withContext
- val page = event.page.coerceIn(0, pages.lastIndex)
- val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
-
- val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
- pageStart,
- state.text
+ _state.value.listState.requestScrollToItem(
+ index = scrollTo,
+ scrollOffset = 0
)
- _state.update {
- it.copy(
- currentPage = page,
- currentChapter = currentChapter,
- currentChapterProgress = currentChapterProgress,
- book = it.book.copy(
- progress = calculateProgress(page),
- scrollIndex = pageStart,
- scrollOffset = 0
- )
- )
- }
-
- updateBookUseCase(_state.value.book)
- syncReadingProgressUseCase(
- _state.value.book,
- _state.value.currentChapter?.title,
- )
- }
- }
-
- is ReaderEvent.OnPagesComputed -> {
- withContext(Dispatchers.Default) {
- val pages = event.pages
- if (pages.isEmpty()) {
- _state.update { it.copy(pages = emptyList(), currentPage = 0) }
- return@withContext
- }
- // Restore the page that contains the saved scrollIndex.
- val savedIndex = _state.value.book.scrollIndex
- val targetPage = pages
- .indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
- .takeIf { it >= 0 }
- ?: 0
- _state.update { it.copy(pages = pages, currentPage = targetPage) }
+ onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
}
}
@@ -361,10 +286,6 @@ class ReaderModel @Inject constructor(
}
updateBookUseCase(_state.value.book)
- syncReadingProgressUseCase(
- _state.value.book,
- _state.value.currentChapter?.title,
- )
LibraryScreen.refreshListChannel.trySend(0)
HistoryScreen.refreshListChannel.trySend(0)
@@ -453,10 +374,6 @@ class ReaderModel @Inject constructor(
is ReaderEvent.OnNavigateToBookInfo -> {
_effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath))
}
-
- is ReaderEvent.OnNavigateToRsvp -> {
- _effects.emit(ReaderEffect.OnNavigateToRsvp(event.bookId))
- }
}
}.also { eventStack.add(it) }
}
@@ -531,10 +448,6 @@ class ReaderModel @Inject constructor(
}
updateBookUseCase(_state.value.book)
- syncReadingProgressUseCase(
- _state.value.book,
- _state.value.currentChapter?.title,
- )
LibraryScreen.refreshListChannel.trySend(0)
HistoryScreen.refreshListChannel.trySend(0)
@@ -557,31 +470,22 @@ class ReaderModel @Inject constructor(
} ?: (-1 to -1)
}
- private fun calculateProgress(pageOrIndex: Int? = null): Float {
- val state = _state.value
+ private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
if (
- state.isLoading ||
- state.text.isEmpty() ||
- state.errorMessage != null
- ) return state.book.progress
+ _state.value.isLoading ||
+ _state.value.listState.layoutInfo.totalItemsCount == 0 ||
+ _state.value.text.isEmpty() ||
+ _state.value.errorMessage != null
+ ) return _state.value.book.progress
- if (state.pages.isNotEmpty()) {
- if (state.pages.size <= 1) return 0f
- val page = pageOrIndex ?: state.currentPage
- return page.coerceIn(0, state.pages.lastIndex)
- .div(state.pages.lastIndex.toFloat())
- .coerceAndPreventNaN()
- }
+ if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f
- if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress
+ val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index
+ if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f
- val index = pageOrIndex ?: state.listState.firstVisibleItemIndex
- if (index == 0) return 0f
-
- val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
- if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
-
- return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
+ return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex)
+ .div(_state.value.text.lastIndex.toFloat())
+ .coerceAndPreventNaN()
}
private suspend inline fun MutableStateFlow.update(function: (T) -> T) {
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt
index b142d758..68efbb25 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderScreen.kt
@@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.presentation.reader
import android.content.pm.ActivityInfo
import android.os.Parcelable
-import android.view.KeyEvent
import android.view.WindowManager
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -24,29 +23,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import androidx.compose.foundation.focusable
-import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
-import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
-import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
@@ -66,7 +55,6 @@ import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
import org.dueattendant149.bookshelf.ui.reader.ReaderContent
import org.dueattendant149.bookshelf.ui.reader.ReaderEffects
-import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
import kotlin.math.roundToInt
@Parcelize
@@ -95,11 +83,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
) {
state.value.listState
}
-
- val configuration = LocalConfiguration.current
- val textMeasurer = rememberTextMeasurer()
-
- val readerPagination = settings.readerPagination.value
val nestedScrollConnection = remember {
derivedStateOf {
object : NestedScrollConnection {
@@ -172,9 +155,76 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
) return@remember 0.sp
(settings.paragraphIndentation.lastValue * 6).sp
}
+ val perceptionExpanderPadding = remember(
+ sidePadding,
+ settings.perceptionExpanderPadding.value
+ ) {
+ sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
+ }
+ val perceptionExpanderThickness = remember(
+ settings.perceptionExpanderThickness.value
+ ) {
+ (settings.perceptionExpanderThickness.lastValue * 0.25f).dp
+ }
+ val horizontalLimiterHeight = remember(
+ settings.horizontalLimiterHeight.value
+ ) {
+ (settings.horizontalLimiterHeight.lastValue * 20).dp
+ }
+ val horizontalLimiterRulerThickness = remember(
+ settings.horizontalLimiterRulerThickness.value
+ ) {
+ (settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
+ }
+ val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
+ (36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
+ }
+ val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
+ when (settings.highlightedReadingThickness.lastValue) {
+ 2 -> FontWeight.SemiBold
+ 3 -> FontWeight.Bold
+ else -> FontWeight.Medium
+ }
+ }
+ val horizontalAlignment = remember(settings.textAlignment.value) {
+ when (settings.textAlignment.lastValue) {
+ ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
+ ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
+ ReaderTextAlignment.END -> Alignment.End
+ }
+ }
val imagesWidth = remember(settings.imagesWidth.value) {
settings.imagesWidth.lastValue.coerceAtLeast(0.01f)
}
+ val imagesCornersRoundness = remember(
+ settings.imagesCornersRoundness.value,
+ settings.imagesWidth.value
+ ) {
+ (settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
+ }
+ val imagesColorEffects = remember(
+ settings.imagesColorEffects.value,
+ fontColor.value,
+ backgroundColor.value
+ ) {
+ when (settings.imagesColorEffects.lastValue) {
+ ReaderColorEffects.OFF -> null
+
+ ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
+ ColorMatrix().apply { setToSaturation(0f) }
+ )
+
+ ReaderColorEffects.FONT -> ColorFilter.tint(
+ color = fontColor.value,
+ blendMode = BlendMode.Color
+ )
+
+ ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
+ color = backgroundColor.value,
+ blendMode = BlendMode.Color
+ )
+ }
+ }
val progressBarPadding = remember(settings.progressBarPadding.value) {
(settings.progressBarPadding.lastValue * 3).dp
}
@@ -237,158 +287,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
}
)
}
-
- // Recompute pages when text or relevant settings change.
- val pages by produceState(
- initialValue = emptyList(),
- state.value.text,
- readerPagination,
- settings.fontSize.value,
- settings.lineHeight.value,
- settings.sidePadding.value,
- settings.paragraphHeight.value,
- settings.textAlignment.value,
- settings.fontFamily.value,
- settings.fontThickness.value,
- settings.italic.value,
- settings.letterSpacing.value,
- settings.paragraphIndentation.value,
- settings.progressBarPadding.value,
- settings.progressBarFontSize.value,
- settings.imagesWidth.value,
- settings.fullscreen.value,
- settings.cutoutPadding.value,
- configuration.screenWidthDp,
- configuration.screenHeightDp,
- ) {
- value = withContext(Dispatchers.Default) {
- if (!readerPagination || state.value.text.isEmpty()) {
- emptyList()
- } else {
- val contentPaddingTopPx = with(density) {
- contentPadding.calculateTopPadding().toPx()
- }.toInt()
- val contentPaddingBottomPx = with(density) {
- contentPadding.calculateBottomPadding().toPx()
- }.toInt()
- val contentPaddingStartPx = with(density) {
- contentPadding.calculateStartPadding(layoutDirection).toPx()
- }.toInt()
- val contentPaddingEndPx = with(density) {
- contentPadding.calculateEndPadding(layoutDirection).toPx()
- }.toInt()
-
- val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
- val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
- val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
-
- val contentWidthPx = with(density) {
- configuration.screenWidthDp.dp.toPx()
- }.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
-
- val progressBarHeightPx = with(density) {
- (progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
- }
-
- val contentHeightPx = with(density) {
- configuration.screenHeightDp.dp.toPx()
- }.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx
-
- val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt()
-
- val style = readerParagraphTextStyle(
- fontFamily = settings.fontFamily.lastValue,
- fontThickness = settings.fontThickness.lastValue,
- fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal,
- textAlignment = settings.textAlignment.lastValue,
- fontSize = settings.fontSize.lastValue.sp,
- lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp,
- letterSpacing = (settings.letterSpacing.lastValue / 100f).em,
- paragraphIndentation = paragraphIndentation,
- )
- org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke(
- text = state.value.text,
- contentWidthPx = contentWidthPx,
- contentHeightPx = contentHeightPx,
- paragraphStyle = style,
- chapterTitleAlignment = settings.chapterTitleAlignment.lastValue,
- paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(),
- textMeasurer = textMeasurer,
- imageMaxWidthPx = imageMaxWidthPx,
- chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(),
- separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(),
- )
- }
- }
- }
-
- val perceptionExpanderPadding = remember(
- sidePadding,
- settings.perceptionExpanderPadding.value
- ) {
- sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
- }
- val perceptionExpanderThickness = remember(
- settings.perceptionExpanderThickness.value
- ) {
- (settings.perceptionExpanderThickness.lastValue * 0.25f).dp
- }
- val horizontalLimiterHeight = remember(
- settings.horizontalLimiterHeight.value
- ) {
- (settings.horizontalLimiterHeight.lastValue * 20).dp
- }
- val horizontalLimiterRulerThickness = remember(
- settings.horizontalLimiterRulerThickness.value
- ) {
- (settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
- }
- val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
- (36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
- }
- val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
- when (settings.highlightedReadingThickness.lastValue) {
- 2 -> FontWeight.SemiBold
- 3 -> FontWeight.Bold
- else -> FontWeight.Medium
- }
- }
- val horizontalAlignment = remember(settings.textAlignment.value) {
- when (settings.textAlignment.lastValue) {
- ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
- ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
- ReaderTextAlignment.END -> Alignment.End
- }
- }
- val imagesCornersRoundness = remember(
- settings.imagesCornersRoundness.value,
- settings.imagesWidth.value
- ) {
- (settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
- }
- val imagesColorEffects = remember(
- settings.imagesColorEffects.value,
- fontColor.value,
- backgroundColor.value
- ) {
- when (settings.imagesColorEffects.lastValue) {
- ReaderColorEffects.OFF -> null
-
- ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
- ColorMatrix().apply { setToSaturation(0f) }
- )
-
- ReaderColorEffects.FONT -> ColorFilter.tint(
- color = fontColor.value,
- blendMode = BlendMode.Color
- )
-
- ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
- color = backgroundColor.value,
- blendMode = BlendMode.Color
- )
- }
- }
val bottomBarPadding = remember(settings.bottomBarPadding.value) {
(settings.bottomBarPadding.lastValue * 4f).dp
}
@@ -438,9 +336,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
LaunchedEffect(Unit) {
screenModel.init(bookId = bookId)
}
- LaunchedEffect(pages) {
- screenModel.onEvent(ReaderEvent.OnPagesComputed(pages))
- }
LaunchedEffect(settings.fullscreen.value) {
screenModel.onEvent(
ReaderEvent.OnMenuVisibility(
@@ -477,45 +372,11 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
-
onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
- DisposableEffect(readerPagination, state.value.pages, state.value.currentPage) {
- if (!readerPagination || state.value.pages.isEmpty()) {
- onDispose { }
- } else {
- val pages = state.value.pages
- val currentRef = java.util.concurrent.atomic.AtomicInteger(state.value.currentPage)
- val originalCallback = activity.window.callback
- val proxy = org.dueattendant149.bookshelf.ui.reader.VolumeKeyWindowCallback(
- delegate = originalCallback,
- onKeyEvent = { event ->
- if (event.action == KeyEvent.ACTION_DOWN) {
- val current = currentRef.get()
- val target = when (event.keyCode) {
- KeyEvent.KEYCODE_VOLUME_UP -> if (current > 0) current - 1 else -1
- KeyEvent.KEYCODE_VOLUME_DOWN -> if (current < pages.lastIndex) current + 1 else -1
- else -> -1
- }
- if (target >= 0) {
- currentRef.set(target)
- screenModel.onEvent(ReaderEvent.OnChangePage(target))
- return@VolumeKeyWindowCallback true
- }
- }
- false
- }
- )
- activity.window.callback = proxy
-
- onDispose {
- activity.window.callback = originalCallback
- }
- }
- }
DisposableEffect(Unit) {
onDispose {
screenModel.clearAsync()
@@ -532,44 +393,32 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
fullscreen = settings.fullscreen.value
)
- Box(
- modifier = Modifier
- .focusable()
- ) {
- ReaderContent(
- book = state.value.book,
- text = state.value.text,
- bottomSheet = state.value.bottomSheet,
- drawer = state.value.drawer,
- listState = listState,
- currentChapter = state.value.currentChapter,
- nestedScrollConnection = nestedScrollConnection.value,
- fastColorPresetChange = settings.fastColorPresetChange.value,
- perceptionExpander = settings.perceptionExpander.value,
- perceptionExpanderPadding = perceptionExpanderPadding,
- perceptionExpanderThickness = perceptionExpanderThickness,
- horizontalLimiter = settings.horizontalLimiter.value,
- horizontalLimiterHeight = horizontalLimiterHeight,
- horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
- horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
- horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
- horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
- currentChapterProgress = state.value.currentChapterProgress,
- isLoading = state.value.isLoading,
- errorMessage = state.value.errorMessage,
- checkpoints = state.value.checkpoints,
- showMenu = state.value.showMenu,
- lockMenu = state.value.lockMenu,
- readerPagination = readerPagination,
- pages = state.value.pages,
- currentPage = state.value.currentPage,
- onPageChanged = { page ->
- if (state.value.pages.isNotEmpty()) {
- screenModel.onEvent(ReaderEvent.OnChangePage(page))
- }
- },
- contentPadding = contentPadding,
- verticalPadding = verticalPadding,
+ ReaderContent(
+ book = state.value.book,
+ text = state.value.text,
+ bottomSheet = state.value.bottomSheet,
+ drawer = state.value.drawer,
+ listState = listState,
+ currentChapter = state.value.currentChapter,
+ nestedScrollConnection = nestedScrollConnection.value,
+ fastColorPresetChange = settings.fastColorPresetChange.value,
+ perceptionExpander = settings.perceptionExpander.value,
+ perceptionExpanderPadding = perceptionExpanderPadding,
+ perceptionExpanderThickness = perceptionExpanderThickness,
+ horizontalLimiter = settings.horizontalLimiter.value,
+ horizontalLimiterHeight = horizontalLimiterHeight,
+ horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
+ horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
+ horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
+ horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
+ currentChapterProgress = state.value.currentChapterProgress,
+ isLoading = state.value.isLoading,
+ errorMessage = state.value.errorMessage,
+ checkpoints = state.value.checkpoints,
+ showMenu = state.value.showMenu,
+ lockMenu = state.value.lockMenu,
+ contentPadding = contentPadding,
+ verticalPadding = verticalPadding,
horizontalGesture = settings.horizontalGesture.value,
horizontalGestureScroll = settings.horizontalGestureScroll.value,
horizontalGestureSensitivity = horizontalGestureSensitivity,
@@ -621,9 +470,7 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
showChaptersDrawer = screenModel::onEvent,
dismissDrawer = screenModel::onEvent,
navigateBack = screenModel::onEvent,
- navigateToBookInfo = screenModel::onEvent,
- navigateToRsvp = screenModel::onEvent
+ navigateToBookInfo = screenModel::onEvent
)
- }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt
index 60aa8023..e46aecd0 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/reader/ReaderState.kt
@@ -12,7 +12,6 @@ import org.dueattendant149.bookshelf.core.BottomSheet
import org.dueattendant149.bookshelf.core.Drawer
import org.dueattendant149.bookshelf.core.ui.UIText
import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
@@ -21,8 +20,6 @@ import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
data class ReaderState(
val book: Book = Book.default,
val text: List = emptyList(),
- val pages: List = emptyList(),
- val currentPage: Int = 0,
val listState: LazyListState = LazyListState(),
val currentChapter: Chapter? = null,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt
deleted file mode 100644
index 5635f394..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEffect.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.rsvp
-
-sealed class RsvpEffect {
- data object OnNavigateBack : RsvpEffect()
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt
deleted file mode 100644
index 6ea608be..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpEvent.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.rsvp
-
-sealed class RsvpEvent {
- data object OnPlayPause : RsvpEvent()
- data object OnStop : RsvpEvent()
- data object OnClose : RsvpEvent()
- data class OnWpmChange(val wpm: Int) : RsvpEvent()
- data class OnSeekToProgress(val progress: Float) : RsvpEvent()
- data class OnSkip(val delta: Int) : RsvpEvent()
- data object OnToggleControls : RsvpEvent()
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt
deleted file mode 100644
index 3f20b981..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpModel.kt
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.rsvp
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asSharedFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import org.dueattendant149.bookshelf.data.settings.SettingsManager
-import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpEngine
-import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpTokenizer
-import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase
-import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
-import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
-import javax.inject.Inject
-import kotlin.math.roundToInt
-
-@HiltViewModel
-class RsvpModel
- @Inject
- constructor(
- private val getBookUseCase: GetBookUseCase,
- private val getTextUseCase: GetTextUseCase,
- private val updateBookUseCase: UpdateBookUseCase,
- private val settings: SettingsManager,
- ) : ViewModel() {
-
- private val engine = RsvpEngine(viewModelScope)
-
- private val _state = MutableStateFlow(RsvpState())
- val state = _state.asStateFlow()
-
- private val _effects = MutableSharedFlow()
- val effects = _effects.asSharedFlow()
-
- val engineState = engine.state
-
- fun init(bookId: Int) {
- viewModelScope.launch(Dispatchers.Default) {
- val book = getBookUseCase(bookId)
- if (book == null) {
- _state.update { it.copy(isLoading = false, errorMessage = "Book not found") }
- _effects.emit(RsvpEffect.OnNavigateBack)
- return@launch
- }
-
- _state.update { it.copy(book = book, isLoading = true, errorMessage = null) }
-
- val text = getTextUseCase(bookId)
- if (text.isEmpty()) {
- _state.update {
- it.copy(
- isLoading = false,
- errorMessage = "Текст недоступен"
- )
- }
- return@launch
- }
-
- val tokens = withContext(Dispatchers.Default) {
- RsvpTokenizer.tokenizeReaderText(text)
- }
-
- if (tokens.isEmpty()) {
- _state.update {
- it.copy(isLoading = false, errorMessage = "Книга пуста")
- }
- return@launch
- }
-
- val startWpm = settings.rsvpWpm.lastValue.takeIf { it in 50..1500 } ?: 350
- val pauseOnParagraph = settings.rsvpPauseOnParagraph.lastValue
- val pauseOnChapter = settings.rsvpPauseOnChapter.lastValue
- val pauseOnLong = settings.rsvpPauseOnLongWords.lastValue
-
- engine.setWpm(startWpm)
- engine.setPauseOnParagraphEnd(pauseOnParagraph)
- engine.setPauseOnChapterEnd(pauseOnChapter)
- engine.setPauseOnLongWords(pauseOnLong)
- engine.setTokens(tokens)
-
- val startIndex = (book.progress * (tokens.lastIndex.coerceAtLeast(1)))
- .roundToInt()
- .coerceIn(0, tokens.lastIndex)
- engine.seekToIndex(startIndex)
-
- _state.update {
- it.copy(
- book = book,
- isLoading = false,
- errorMessage = null,
- )
- }
- }
- }
-
- fun onEvent(event: RsvpEvent) {
- when (event) {
- RsvpEvent.OnPlayPause -> engine.toggle()
- RsvpEvent.OnStop -> engine.stop()
- RsvpEvent.OnClose -> {
- persistProgressAndClose()
- }
-
- is RsvpEvent.OnWpmChange -> {
- val clamped = event.wpm.coerceIn(50, 1500)
- engine.setWpm(clamped)
- settings.rsvpWpm.update(clamped)
- }
-
- is RsvpEvent.OnSeekToProgress -> {
- engine.seekToProgress(event.progress)
- }
-
- is RsvpEvent.OnSkip -> {
- if (event.delta >= 0) engine.skipForward(event.delta)
- else engine.skipBackward(-event.delta)
- }
-
- RsvpEvent.OnToggleControls -> {
- _state.update { it.copy(showControls = !it.showControls) }
- }
- }
- }
-
- private fun persistProgressAndClose() {
- viewModelScope.launch {
- val es = engine.state.value
- val currentBook = state.value.book
- if (es.tokens.isNotEmpty() && currentBook.id != 0) {
- val newProgress = es.currentIndex.toFloat() /
- es.tokens.lastIndex.coerceAtLeast(1).toFloat()
- updateBookUseCase(
- currentBook.copy(
- progress = newProgress.coerceIn(0f, 1f),
- )
- )
- }
- engine.stop()
- _effects.emit(RsvpEffect.OnNavigateBack)
- }
- }
-
- override fun onCleared() {
- super.onCleared()
- engine.stop()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt
deleted file mode 100644
index c5fa954d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpScreen.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.rsvp
-
-import android.os.Parcelable
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.hilt.navigation.compose.hiltViewModel
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-import org.dueattendant149.bookshelf.ui.rsvp.RsvpContent
-
-@Parcelize
-data class RsvpScreen(val bookId: Int) : Screen, Parcelable {
-
- @Composable
- override fun Content() {
- val model = hiltViewModel()
- val navigator = LocalNavigator.current
-
- LaunchedEffect(bookId) {
- model.init(bookId)
- }
- LaunchedEffect(model.effects, navigator) {
- model.effects.collectLatest { effect ->
- when (effect) {
- RsvpEffect.OnNavigateBack -> navigator.pop()
- }
- }
- }
-
- RsvpContent(model = model)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt
deleted file mode 100644
index 52d2ee84..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/rsvp/RsvpState.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.rsvp
-
-import androidx.compose.runtime.Immutable
-import org.dueattendant149.bookshelf.domain.model.library.Book
-
-@Immutable
-data class RsvpState(
- val book: Book = Book.default,
- val isLoading: Boolean = true,
- val errorMessage: String? = null,
- val showControls: Boolean = true,
-)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt
deleted file mode 100644
index 1237272d..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchModel.kt
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.search
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse
-import org.dueattendant149.bookshelf.domain.use_case.remote.GlobalSearchUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.ListDownloadsUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.StartDownloadUseCase
-import org.dueattendant149.bookshelf.domain.use_case.remote.UploadBookUseCase
-import java.io.File
-import javax.inject.Inject
-
-data class SearchState(
- val query: String = "",
- val results: List = emptyList(),
- val isLoading: Boolean = false,
- val error: String? = null,
- val downloadingTitle: String? = null,
- val downloadSuccess: String? = null,
- val downloadProgresses: Map = emptyMap(),
- val uploadSuccess: String? = null,
- val isUploading: Boolean = false,
-)
-
-@HiltViewModel
-class SearchModel
- @Inject
- constructor(
- private val globalSearchUseCase: GlobalSearchUseCase,
- private val startDownloadUseCase: StartDownloadUseCase,
- private val listDownloadsUseCase: ListDownloadsUseCase,
- private val uploadBookUseCase: UploadBookUseCase,
- ) : ViewModel() {
- private val _state = MutableStateFlow(SearchState())
- val state = _state.asStateFlow()
-
- private var pollJob: Job? = null
-
- fun setQuery(query: String) {
- _state.update { it.copy(query = query) }
- }
-
- fun search() {
- val query = state.value.query.trim()
- if (query.isBlank()) return
-
- viewModelScope.launch {
- _state.update { it.copy(isLoading = true, error = null, results = emptyList()) }
- val result = globalSearchUseCase(SearchRequest(query = query))
- _state.update {
- it.copy(
- isLoading = false,
- results = result.getOrDefault(SearchResponse(emptyList())).results,
- error = result.exceptionOrNull()?.message,
- )
- }
- }
- }
-
- fun startDownload(item: SearchResultItemResponse) {
- viewModelScope.launch {
- _state.update { it.copy(downloadingTitle = item.title, downloadSuccess = null) }
- val request = DownloadRequest(
- source = item.source,
- title = item.title,
- author = item.author,
- downloadUrl = item.downloadUrl.ifBlank { null },
- magnetUrl = item.magnetUrl.ifBlank { null },
- infoHash = item.infoHash.ifBlank { null },
- md5 = item.md5.ifBlank { null },
- url = item.url.ifBlank { null },
- mediaType = item.mediaType.ifBlank { "ebook" },
- downloadProtocol = item.downloadProtocol,
- )
- val result = startDownloadUseCase(request)
- _state.update {
- it.copy(
- downloadingTitle = null,
- downloadSuccess = if (result.isSuccess) item.title else null,
- error = result.exceptionOrNull()?.message,
- )
- }
- if (result.isSuccess) {
- startPolling()
- }
- }
- }
-
- fun uploadBook(file: File, bookType: String) {
- viewModelScope.launch {
- _state.update { it.copy(isUploading = true, uploadSuccess = null, error = null) }
- val result = uploadBookUseCase(file, bookType)
- _state.update {
- it.copy(
- isUploading = false,
- uploadSuccess = result.getOrNull(),
- error = result.exceptionOrNull()?.message,
- )
- }
- }
- }
-
- fun clearUploadSuccess() {
- _state.update { it.copy(uploadSuccess = null) }
- }
-
- fun clearDownloadSuccess() {
- _state.update { it.copy(downloadSuccess = null) }
- }
-
- private fun startPolling() {
- pollJob?.cancel()
- pollJob = viewModelScope.launch {
- while (true) {
- delay(3000L)
- val result = listDownloadsUseCase()
- result.onSuccess { list ->
- val map = list.downloads.associateBy { it.title }
- _state.update { it.copy(downloadProgresses = map) }
- }
- }
- }
- }
-
- override fun onCleared() {
- super.onCleared()
- pollJob?.cancel()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt
deleted file mode 100644
index 61847a64..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/search/SearchScreen.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.search
-
-import android.os.Parcelable
-import androidx.compose.runtime.Composable
-import androidx.hilt.navigation.compose.hiltViewModel
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.ui.search.SearchContent
-
-@Parcelize
-object SearchScreen : Screen, Parcelable {
-
- @Composable
- override fun Content() {
- val model = hiltViewModel()
- SearchContent(model = model)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt
index 365cf4b8..ccee3aba 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt
@@ -18,8 +18,6 @@ import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl
-import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
-import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject
data class BookshelfSettingsState(
@@ -110,20 +108,8 @@ class BookshelfSettingsModel
fun save() {
viewModelScope.launch {
val current = _state.value
- val normalizedBookshelfUrl = current.bookshelfUrl.trim().let {
- if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
- }
- val normalizedAbsUrl = current.absUrl.trim().let {
- if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
- }
- _state.update {
- it.copy(
- bookshelfUrl = normalizedBookshelfUrl,
- absUrl = normalizedAbsUrl,
- )
- }
- serverSettings.setBookshelfUrl(normalizedBookshelfUrl)
- serverSettings.setAbsUrl(normalizedAbsUrl)
+ serverSettings.setBookshelfUrl(current.bookshelfUrl)
+ serverSettings.setAbsUrl(current.absUrl)
serverSettings.setAbsToken(current.absToken)
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt
index f25ed13b..8fd8afcf 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/SettingsScreen.kt
@@ -10,7 +10,6 @@ import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen
@@ -30,12 +29,9 @@ object SettingsScreen : Screen, Parcelable {
val settings = LocalSettings.current
val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior()
- val isRoot = navigator.items.collectAsStateWithLifecycle().value.size <= 1
-
SettingsContent(
listState = listState,
scrollBehavior = scrollBehavior,
- isRoot = isRoot,
navigateToGeneralSettings = {
navigator.push(GeneralSettingsScreen)
},
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt
deleted file mode 100644
index 631f96cd..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsModel.kt
+++ /dev/null
@@ -1,220 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.tts
-
-import android.app.Application
-import android.util.Log
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.Observer
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import androidx.work.Constraints
-import androidx.work.ExistingWorkPolicy
-import androidx.work.NetworkType
-import androidx.work.OneTimeWorkRequestBuilder
-import androidx.work.WorkInfo
-import androidx.work.WorkManager
-import dagger.hilt.android.lifecycle.HiltViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookshelf.data.worker.TtsDownloadWorker
-import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
-import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
-import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
-import org.dueattendant149.bookshelf.domain.use_case.tts.CreateTtsJobUseCase
-import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsEnginesUseCase
-import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsVoicesUseCase
-import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase
-import org.dueattendant149.bookshelf.R
-import javax.inject.Inject
-
-data class TtsState(
- val engines: List = emptyList(),
- val voices: List = emptyList(),
- val selectedEngine: TtsEngine? = null,
- val selectedVoice: TtsVoice? = null,
- val speed: Double = 1.0,
- val job: TtsJob? = null,
- val isLoading: Boolean = false,
- val isCreatingJob: Boolean = false,
- val error: String? = null,
- val outputFilePath: String? = null,
-)
-
-@HiltViewModel
-class TtsModel
- @Inject
- constructor(
- private val application: Application,
- private val fetchTtsEnginesUseCase: FetchTtsEnginesUseCase,
- private val fetchTtsVoicesUseCase: FetchTtsVoicesUseCase,
- private val createTtsJobUseCase: CreateTtsJobUseCase,
- private val getTtsJobUseCase: GetTtsJobUseCase,
- ) : ViewModel() {
-
- private val _state = MutableStateFlow(TtsState())
- val state = _state.asStateFlow()
-
- private val workManager = WorkManager.getInstance(application)
-
- private var observedWork: LiveData? = null
- private var workObserver: Observer? = null
-
- fun load(bookRemoteId: String) {
- viewModelScope.launch {
- _state.update { it.copy(isLoading = true, error = null) }
-
- val enginesResult = fetchTtsEnginesUseCase()
- val engines = enginesResult.getOrDefault(emptyList())
-
- val selectedEngine = engines.firstOrNull()
- val voicesResult = selectedEngine?.let { fetchTtsVoicesUseCase(it.id) }
- ?: fetchTtsVoicesUseCase()
- val voices = voicesResult?.getOrDefault(emptyList()) ?: emptyList()
-
- _state.update {
- it.copy(
- isLoading = false,
- engines = engines,
- voices = voices,
- selectedEngine = selectedEngine,
- selectedVoice = voices.firstOrNull(),
- error = enginesResult.exceptionOrNull()?.message
- ?: voicesResult?.exceptionOrNull()?.message,
- )
- }
- }
- }
-
- fun selectEngine(engine: TtsEngine) {
- _state.update { it.copy(selectedEngine = engine, selectedVoice = null) }
- viewModelScope.launch {
- _state.update { it.copy(isLoading = true, error = null) }
- val result = fetchTtsVoicesUseCase(engine.id)
- _state.update {
- it.copy(
- isLoading = false,
- voices = result.getOrDefault(emptyList()),
- selectedVoice = result.getOrDefault(emptyList()).firstOrNull(),
- error = result.exceptionOrNull()?.message,
- )
- }
- }
- }
-
- fun selectVoice(voice: TtsVoice) {
- _state.update { it.copy(selectedVoice = voice) }
- }
-
- fun updateSpeed(speed: Double) {
- _state.update { it.copy(speed = speed.coerceIn(0.5, 2.0)) }
- }
-
- fun createJob(bookRemoteId: String) {
- val engine = _state.value.selectedEngine ?: return
- val voice = _state.value.selectedVoice ?: return
-
- viewModelScope.launch {
- _state.update { it.copy(isCreatingJob = true, error = null, job = null, outputFilePath = null) }
- val result = createTtsJobUseCase(
- bookId = bookRemoteId,
- engine = engine.id,
- voiceId = voice.id,
- speed = _state.value.speed,
- )
- val job = result.getOrElse {
- _state.update { it.copy(isCreatingJob = false, error = it.error ?: application.getString(R.string.tts_create_job_failed)) }
- return@launch
- }
- _state.update { it.copy(isCreatingJob = false, job = job) }
- enqueueDownloadWorker(job, bookRemoteId)
- }
- }
-
- fun refreshJob(jobId: String) {
- viewModelScope.launch {
- val result = getTtsJobUseCase(jobId)
- _state.update {
- it.copy(
- job = result.getOrNull() ?: it.job,
- error = result.exceptionOrNull()?.message ?: it.error,
- )
- }
- }
- }
-
- private fun enqueueDownloadWorker(job: TtsJob, bookRemoteId: String) {
- val request = OneTimeWorkRequestBuilder()
- .setConstraints(
- Constraints.Builder()
- .setRequiredNetworkType(NetworkType.CONNECTED)
- .build()
- )
- .setInputData(
- TtsDownloadWorker.createInputData(
- jobId = job.jobId,
- bookId = bookRemoteId,
- )
- )
- .build()
-
- workManager.enqueueUniqueWork(
- "tts_download_${job.jobId}",
- ExistingWorkPolicy.KEEP,
- request,
- )
-
- observeWorker(request.id)
- }
-
- private fun observeWorker(workId: java.util.UUID) {
- workObserver?.let { observer ->
- observedWork?.removeObserver(observer)
- }
-
- val liveData = workManager.getWorkInfoByIdLiveData(workId)
- val observer: Observer = Observer { info ->
- info ?: return@Observer
- when (info.state) {
- WorkInfo.State.SUCCEEDED -> {
- val filePath = info.outputData.getString(TtsDownloadWorker.KEY_OUTPUT_FILE_PATH)
- _state.update { it.copy(outputFilePath = filePath) }
- }
-
- WorkInfo.State.FAILED -> {
- val message = info.outputData.getString(TtsDownloadWorker.KEY_ERROR_MESSAGE)
- _state.update { it.copy(error = message ?: application.getString(R.string.tts_download_failed)) }
- }
-
- WorkInfo.State.RUNNING -> {
- val progress = info.progress.getDouble(TtsDownloadWorker.PROGRESS_PROGRESS, 0.0)
- val status = info.progress.getString(TtsDownloadWorker.PROGRESS_STATUS) ?: ""
- _state.update {
- it.copy(
- job = it.job?.copy(status = status, progress = progress)
- )
- }
- }
-
- else -> {}
- }
- }
-
- observedWork = liveData
- workObserver = observer
- liveData.observeForever(observer)
- }
-
- override fun onCleared() {
- workObserver?.let { observer ->
- observedWork?.removeObserver(observer)
- }
- super.onCleared()
- }
- }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt
deleted file mode 100644
index 680ee0a5..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/tts/TtsScreen.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.presentation.tts
-
-import android.os.Parcelable
-import androidx.compose.runtime.Composable
-import androidx.hilt.navigation.compose.hiltViewModel
-import kotlinx.parcelize.Parcelize
-import org.dueattendant149.bookshelf.presentation.navigator.Screen
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-import org.dueattendant149.bookshelf.ui.tts.TtsContent
-
-@Parcelize
-data class TtsScreen(val bookRemoteId: String) : Screen, Parcelable {
-
- @Composable
- override fun Content() {
- val model = hiltViewModel()
- val navigator = LocalNavigator.current
- TtsContent(
- model = model,
- bookRemoteId = bookRemoteId,
- navigateBack = navigator::pop,
- )
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt
index 87b3b8d3..69764fac 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/bookshelf/RemoteLibraryContent.kt
@@ -8,25 +8,35 @@ package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Download
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -40,62 +50,35 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
+import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
-import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel
-import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
-import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
-import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton
+import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
-fun RemoteLibraryContent(
- model: RemoteLibraryViewModel,
- titleRes: Int,
- onPlayAudio: (Book) -> Unit,
- navigateToTts: (bookRemoteId: String) -> Unit,
-) {
+fun RemoteLibraryContent(model: RemoteLibraryModel) {
val state by model.state.collectAsStateWithLifecycle()
- val navigator = LocalNavigator.current
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = model::loadLibraries
)
- val sortedBooks = remember(state.books) {
- state.books.sortedByDescending { it.lastOpened ?: 0L }
- }
-
- val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) {
- if (!state.offlineOnly) sortedBooks
- else sortedBooks.filter { book ->
- when (state.cacheStatuses[book.remoteId]?.state) {
- CacheState.COMPLETED,
- CacheState.DOWNLOADING,
- CacheState.PENDING -> true
- else -> false
- }
+ val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
+ if (!state.offlineOnly) state.books
+ else state.books.filter { book ->
+ val status = state.cacheStatuses[book.remoteId]
+ status?.state == CacheState.COMPLETED ||
+ status?.state == CacheState.DOWNLOADING ||
+ status?.state == CacheState.PENDING
}
}
- val onlineIndicator = remember(state.serverOnline) {
- if (state.serverOnline) ""
- else " • Offline"
- }
-
Scaffold(
- contentWindowInsets = WindowInsets(0),
topBar = {
TopAppBar(
- title = {
- Text(
- text = stringResource(titleRes) + onlineIndicator,
- style = MaterialTheme.typography.titleLarge
- )
- }
+ title = { Text(stringResource(R.string.bookshelf_screen)) }
)
}
) { padding ->
@@ -110,71 +93,95 @@ fun RemoteLibraryContent(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
- items(
- items = visibleBooks,
- key = { it.remoteId }
- ) { book ->
- val cacheStatus = state.cacheStatuses[book.remoteId]
- val isCaching = state.cachingBookId == book.remoteId
-
- val progress = when {
- book.hasAudio -> {
- val dur = book.audioDuration.coerceAtLeast(1L)
- (book.audioCurrentPosition.toFloat() / dur).coerceIn(0f, 1f)
- }
- else -> book.progress.coerceIn(0f, 1f)
- }
-
- val primaryLabel: String
- val secondaryLabel: String
- val onPrimaryClick: () -> Unit
- val onSecondaryClick: () -> Unit
-
- when {
- book.hasAudio -> {
- primaryLabel = stringResource(R.string.bookshelf_play_audio_action)
- secondaryLabel = stringResource(R.string.chapters_button)
- onPrimaryClick = { onPlayAudio(book) }
- onSecondaryClick = { navigator.push(ChapterListScreen(book)) }
- }
- book.hasEbook -> {
- primaryLabel = stringResource(R.string.read)
- secondaryLabel = stringResource(R.string.tts_button)
- onPrimaryClick = { model.openBook(book) }
- onSecondaryClick = { navigateToTts(book.remoteId) }
- }
- else -> {
- primaryLabel = ""
- secondaryLabel = ""
- onPrimaryClick = {}
- onSecondaryClick = {}
- }
- }
-
- BookCard(
- book = book,
- progress = progress,
- primaryButton = {
- if (primaryLabel.isNotBlank()) {
- PrimaryActionButton(
- label = primaryLabel,
- enabled = !isCaching,
- onClick = onPrimaryClick
- )
- }
- },
- secondaryButton = {
- if (secondaryLabel.isNotBlank()) {
- SecondaryActionButton(
- label = secondaryLabel,
- onClick = onSecondaryClick
- )
- }
- }
+ item {
+ Text(
+ text = stringResource(R.string.bookshelf_libraries_header),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(bottom = 4.dp)
)
}
- if (state.isLoading && state.books.isEmpty()) {
+ items(state.libraries.size) { index ->
+ val library = state.libraries[index]
+ val selected = state.selectedLibrary?.id == library.id
+ Card(
+ onClick = { model.selectLibrary(library) },
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = library.name,
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ supportingContent = {
+ Text(
+ text = stringResource(
+ R.string.bookshelf_library_subtitle,
+ library.mediaType,
+ library.itemCount
+ )
+ )
+ },
+ trailingContent = {
+ if (selected) {
+ Text(
+ text = stringResource(R.string.bookshelf_selected_label),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ )
+ }
+ }
+
+ state.selectedLibrary?.let { library ->
+ item {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.bookshelf_books_header, library.name),
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = stringResource(R.string.bookshelf_offline_only_label),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Switch(
+ checked = state.offlineOnly,
+ onCheckedChange = { model.toggleOfflineOnly() }
+ )
+ }
+ }
+ }
+
+ items(visibleBooks.size) { index ->
+ val book = visibleBooks[index]
+ val cacheStatus = state.cacheStatuses[book.remoteId]
+ val isCaching = state.cachingBookId == book.remoteId
+ BookListItem(
+ book = book,
+ cacheStatus = cacheStatus,
+ isCaching = isCaching,
+ onDownload = { model.cacheBook(book) },
+ onDelete = { model.deleteCache(book) },
+ onRead = if (book.hasEbook) ({ model.openBook(book) }) else null
+ )
+ }
+
+ if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
item {
Box(
modifier = Modifier
@@ -188,7 +195,7 @@ fun RemoteLibraryContent(
}
}
- if (!state.isLoading && state.books.isEmpty() && state.error == null) {
+ if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
EmptyPlaceholder(
modifier = Modifier.align(Alignment.Center),
message = stringResource(R.string.bookshelf_empty_message),
@@ -198,7 +205,6 @@ fun RemoteLibraryContent(
)
}
-
state.error?.let { error ->
ErrorPlaceholder(
modifier = Modifier.align(Alignment.Center),
@@ -220,4 +226,102 @@ fun RemoteLibraryContent(
}
}
+@Composable
+private fun BookListItem(
+ book: Book,
+ cacheStatus: CacheStatus?,
+ isCaching: Boolean,
+ onDownload: () -> Unit,
+ onDelete: () -> Unit,
+ onRead: (() -> Unit)?,
+) {
+ val cacheState = cacheStatus?.state ?: CacheState.NONE
+ val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
+ val showDelete = cacheState != CacheState.NONE
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(
+ text = book.title,
+ style = MaterialTheme.typography.titleMedium
+ )
+ },
+ supportingContent = {
+ Column {
+ Text(
+ text = book.author.getAsString()
+ ?: stringResource(R.string.unknown_author)
+ )
+ if (cacheState != CacheState.NONE) {
+ Text(
+ text = cacheStatusLabel(cacheState),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ },
+ trailingContent = {
+ Row {
+ if (showDownload) {
+ IconButton(onClick = onDownload) {
+ Icon(
+ imageVector = Icons.Default.Download,
+ contentDescription = stringResource(R.string.bookshelf_download_action)
+ )
+ }
+ }
+ if (showDelete) {
+ IconButton(onClick = onDelete) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = stringResource(R.string.bookshelf_delete_cache_action)
+ )
+ }
+ }
+ }
+ }
+ )
+
+ if (cacheState == CacheState.DOWNLOADING) {
+ LinearProgressIndicator(
+ progress = { cacheStatus?.progress?.coerceIn(0f, 1f) ?: 0f },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+
+ onRead?.let { read ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 12.dp),
+ horizontalArrangement = Arrangement.End
+ ) {
+ Button(
+ onClick = read,
+ enabled = !isCaching
+ ) {
+ Text(stringResource(R.string.read))
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun cacheStatusLabel(state: CacheState): String =
+ when (state) {
+ CacheState.NONE -> ""
+ CacheState.PENDING -> stringResource(R.string.bookshelf_cache_pending)
+ CacheState.DOWNLOADING -> stringResource(R.string.bookshelf_cache_downloading)
+ CacheState.COMPLETED -> stringResource(R.string.bookshelf_cache_completed)
+ CacheState.FAILED -> stringResource(R.string.bookshelf_cache_failed)
+ }
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt
deleted file mode 100644
index 05d6e94c..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCard.kt
+++ /dev/null
@@ -1,184 +0,0 @@
-package org.dueattendant149.bookshelf.ui.common.components.book
-
-import android.net.Uri
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.IntrinsicSize
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.aspectRatio
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
-import coil.request.ImageRequest
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.domain.model.library.Book
-
-@Composable
-fun BookCard(
- book: Book,
- progress: Float,
- primaryButton: @Composable (() -> Unit)? = null,
- secondaryButton: @Composable (() -> Unit)? = null,
- coverBadge: (@Composable () -> Unit)? = null,
- modifier: Modifier = Modifier,
-) {
- val shape = RoundedCornerShape(16.dp)
- val coercedProgress = progress.coerceIn(0f, 1f)
- Card(
- modifier = modifier
- .fillMaxWidth()
- .shadow(elevation = 4.dp, shape = shape, clip = false),
- shape = shape,
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
- ),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(IntrinsicSize.Min)
- .padding(12.dp),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- // Fixed-height vertical cover on the left
- Box(
- modifier = Modifier
- .height(120.dp)
- .aspectRatio(2f / 3f)
- .clip(RoundedCornerShape(12.dp))
- .background(MaterialTheme.colorScheme.surfaceVariant),
- contentAlignment = Alignment.Center
- ) {
- if (book.coverUrl.isNotBlank()) {
- AsyncImage(
- model = ImageRequest.Builder(LocalContext.current)
- .data(Uri.parse(book.coverUrl))
- .crossfade(200)
- .build(),
- contentDescription = stringResource(R.string.cover_image_content_desc),
- modifier = Modifier.fillMaxSize(),
- contentScale = ContentScale.Crop,
- )
- } else {
- CoverPlaceholder(book = book)
- }
- coverBadge?.invoke()
- }
-
- Spacer(Modifier.width(12.dp))
-
- // Title / author / progress in the middle
- Column(
- modifier = Modifier
- .weight(1f)
- .fillMaxHeight(),
- verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically)
- ) {
- Text(
- text = book.title,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- )
- Text(
- text = book.author.getAsString() ?: stringResource(R.string.unknown_author),
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-
- Spacer(Modifier.weight(1f))
-
- LinearProgressIndicator(
- progress = { coercedProgress },
- modifier = Modifier
- .fillMaxWidth()
- .height(4.dp)
- .clip(RoundedCornerShape(2.dp)),
- color = MaterialTheme.colorScheme.primary,
- trackColor = MaterialTheme.colorScheme.surfaceVariant,
- )
- }
-
- Spacer(Modifier.width(12.dp))
-
- // Two pill buttons stacked vertically on the right
- Column(
- modifier = Modifier.fillMaxHeight(),
- verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- secondaryButton?.invoke()
- primaryButton?.invoke()
- }
- }
- }
-}
-
-@Composable
-private fun CoverPlaceholder(book: Book) {
- val seed = remember(book.title, book.author.getAsString()) {
- book.title + book.author.getAsString()
- }
- val color = rememberCoverColor(seed = seed)
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(color),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = book.title.take(1).uppercase(),
- style = MaterialTheme.typography.headlineMedium,
- color = Color.White,
- fontWeight = FontWeight.Bold,
- )
- }
-}
-
-@Composable
-private fun rememberCoverColor(seed: String): Color {
- val palette = remember {
- listOf(
- Color(0xFF1E88E5),
- Color(0xFF43A047),
- Color(0xFFE53935),
- Color(0xFF8E24AA),
- Color(0xFFFDD835),
- Color(0xFF00897B),
- Color(0xFF3949AB),
- Color(0xFFD81B60),
- )
- }
- val index = (seed.hashCode() % palette.size).let { if (it < 0) it + palette.size else it }
- return palette[index]
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt
deleted file mode 100644
index b5cdce77..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/common/components/book/BookCardButtons.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.dueattendant149.bookshelf.ui.common.components.book
-
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-
-@Composable
-fun PrimaryActionButton(
- label: String,
- onClick: () -> Unit,
- enabled: Boolean = true,
-) {
- Button(
- onClick = onClick,
- enabled = enabled,
- shape = RoundedCornerShape(50),
- modifier = Modifier
- .widthIn(min = 84.dp)
- .height(40.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.primary,
- contentColor = MaterialTheme.colorScheme.onPrimary,
- )
- ) {
- Text(label, style = MaterialTheme.typography.labelLarge)
- }
-}
-
-@Composable
-fun SecondaryActionButton(
- label: String,
- onClick: () -> Unit,
- enabled: Boolean = true,
-) {
- Button(
- onClick = onClick,
- enabled = enabled,
- shape = RoundedCornerShape(50),
- modifier = Modifier
- .widthIn(min = 84.dp)
- .height(40.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondaryContainer,
- contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
- )
- ) {
- Text(label, style = MaterialTheme.typography.labelLarge)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt
deleted file mode 100644
index bc70b256..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/player/PlayerContent.kt
+++ /dev/null
@@ -1,272 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.player
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material.icons.filled.CloudDownload
-import androidx.compose.material.icons.filled.Pause
-import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material.icons.filled.SkipNext
-import androidx.compose.material.icons.filled.SkipPrevious
-import androidx.compose.material3.Card
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import kotlinx.coroutines.flow.StateFlow
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.presentation.player.PlayerState
-import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
-import kotlin.math.roundToInt
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun PlayerContent(
- state: StateFlow,
- onPlayPause: () -> Unit,
- onSeek: (Long) -> Unit,
- onSeekForward: () -> Unit,
- onSeekBackward: () -> Unit,
- onSkipNext: () -> Unit,
- onSkipPrevious: () -> Unit,
- onSetSpeed: (Float) -> Unit,
- onSelectTrack: (Int) -> Unit,
- onOpenChapters: () -> Unit,
- onDownloadAudiobook: () -> Unit,
-) {
- val playerState by state.collectAsStateWithLifecycle()
- val navigator = LocalNavigator.current
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(playerState.book.title) },
- navigationIcon = {
- IconButton(onClick = { navigator.pop() }) {
- Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
- contentDescription = stringResource(R.string.go_back_content_desc)
- )
- }
- }
- )
- }
- ) { padding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- text = playerState.book.author.getAsString() ?: "",
- style = MaterialTheme.typography.bodyLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-
- val currentTrack = playerState.tracks.getOrNull(playerState.currentTrackIndex)
- Text(
- text = currentTrack?.title ?: stringResource(R.string.player_no_track),
- style = MaterialTheme.typography.titleMedium,
- maxLines = 2,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(horizontal = 8.dp)
- )
-
- if (playerState.isLoading) {
- CircularProgressIndicator()
- }
-
- playerState.error?.let { error ->
- Text(
- text = error,
- color = MaterialTheme.colorScheme.error,
- style = MaterialTheme.typography.bodyMedium,
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- val duration = playerState.durationMs.coerceAtLeast(1L)
- val positionFraction = remember(playerState.currentPositionMs, duration) {
- (playerState.currentPositionMs / duration.toFloat()).coerceIn(0f, 1f)
- }
- var sliderPosition by remember(positionFraction) { mutableFloatStateOf(positionFraction) }
- Slider(
- value = sliderPosition,
- onValueChange = { sliderPosition = it },
- onValueChangeFinished = {
- onSeek((sliderPosition * duration).roundToInt().toLong())
- },
- modifier = Modifier.fillMaxWidth(),
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(formatMs(playerState.currentPositionMs))
- Text(formatMs(playerState.durationMs))
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically
- ) {
- IconButton(onClick = onSeekBackward) {
- Text(stringResource(R.string.player_seek_backward))
- }
- IconButton(onClick = onSkipPrevious) {
- Icon(Icons.Default.SkipPrevious, contentDescription = stringResource(R.string.player_previous))
- }
- IconButton(
- onClick = onPlayPause,
- modifier = Modifier.size(64.dp)
- ) {
- Icon(
- imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
- contentDescription = stringResource(
- if (playerState.isPlaying) R.string.player_pause else R.string.player_play
- ),
- modifier = Modifier.size(48.dp)
- )
- }
- IconButton(onClick = onSkipNext) {
- Icon(Icons.Default.SkipNext, contentDescription = stringResource(R.string.player_next))
- }
- IconButton(onClick = onSeekForward) {
- Text(stringResource(R.string.player_seek_forward))
- }
- }
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly
- ) {
- listOf(0.5f, 1f, 1.25f, 1.5f, 2f).forEach { speed ->
- TextButton(
- onClick = { onSetSpeed(speed) },
- ) {
- Text(
- text = "${speed}x",
- color = if (playerState.playbackSpeed == speed) {
- MaterialTheme.colorScheme.primary
- } else {
- MaterialTheme.colorScheme.onSurface
- }
- )
- }
- }
- }
-
- IconButton(
- onClick = onOpenChapters,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text("Главы")
- }
-
- IconButton(
- onClick = onDownloadAudiobook,
- enabled = !playerState.isCaching,
- modifier = Modifier.fillMaxWidth()
- ) {
- if (playerState.isCaching) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- CircularProgressIndicator(
- modifier = Modifier.size(24.dp),
- strokeWidth = 2.dp
- )
- Spacer(Modifier.height(4.dp))
- LinearProgressIndicator(
- progress = { playerState.cacheProgress.coerceIn(0f, 1f) },
- modifier = Modifier
- .fillMaxWidth(0.6f)
- .height(4.dp),
- )
- }
- } else {
- Icon(
- imageVector = Icons.Default.CloudDownload,
- contentDescription = "Скачать аудиокнигу"
- )
- }
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- LazyColumn(
- modifier = Modifier.fillMaxWidth().weight(1f),
- verticalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- itemsIndexed(
- items = playerState.tracks,
- key = { _, track -> track.fileId }
- ) { index, track ->
- val selected = index == playerState.currentTrackIndex
- Card(
- onClick = { onSelectTrack(index) },
- modifier = Modifier.fillMaxWidth(),
- ) {
- Text(
- text = "${index + 1}. ${track.title}",
- color = if (selected) MaterialTheme.colorScheme.primary
- else MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.padding(16.dp)
- )
- }
- }
- }
- }
- }
-}
-
-private fun formatMs(ms: Long): String {
- val totalSeconds = ms / 1000
- val hours = totalSeconds / 3600
- val minutes = (totalSeconds % 3600) / 60
- val seconds = totalSeconds % 60
- return if (hours > 0) {
- String.format("%d:%02d:%02d", hours, minutes, seconds)
- } else {
- String.format("%02d:%02d", minutes, seconds)
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt
index d43a0d6f..214f0847 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderBottomBar.kt
@@ -50,7 +50,6 @@ import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
import org.dueattendant149.bookshelf.ui.common.model.Direction
import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition
import org.dueattendant149.bookshelf.ui.theme.readerBarsColor
-import kotlin.math.roundToInt
@Composable
fun ReaderBottomBar(
@@ -61,10 +60,6 @@ fun ReaderBottomBar(
lockMenu: Boolean,
checkpoints: List,
bottomBarPadding: Dp,
- readerPagination: Boolean,
- pages: List,
- currentPage: Int,
- onChangePage: (Int) -> Unit,
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
@@ -143,17 +138,13 @@ fun ReaderBottomBar(
}
},
slider = {
- ReaderBottomBarSlider(
- book = book,
- lockMenu = lockMenu,
- listState = listState,
- readerPagination = readerPagination,
- pages = pages,
- currentPage = currentPage,
- onChangePage = onChangePage,
- scroll = scroll,
- changeProgress = changeProgress
- )
+ ReaderBottomBarSlider(
+ book = book,
+ lockMenu = lockMenu,
+ listState = listState,
+ scroll = scroll,
+ changeProgress = changeProgress
+ )
},
indicator = {
ReaderBottomBarCheckpointsIndicator(
@@ -249,10 +240,6 @@ private fun ReaderBottomBarSlider(
book: Book,
lockMenu: Boolean,
listState: LazyListState,
- readerPagination: Boolean,
- pages: List,
- currentPage: Int,
- onChangePage: (Int) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
) {
@@ -268,17 +255,7 @@ private fun ReaderBottomBarSlider(
value = progress,
enabled = !lockMenu,
onValueChange = {
- if (readerPagination && pages.isNotEmpty()) {
- val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex)
- onChangePage(page)
- changeProgress(
- ReaderEvent.OnChangeProgress(
- progress = it,
- firstVisibleItemIndex = pages[page].startTextIndex,
- firstVisibleItemOffset = 0
- )
- )
- } else if (listState.layoutInfo.totalItemsCount > 0) {
+ if (listState.layoutInfo.totalItemsCount > 0) {
scroll(ReaderEvent.OnScroll(it))
changeProgress(
ReaderEvent.OnChangeProgress(
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt
index f05326e4..64a38678 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderContent.kt
@@ -58,10 +58,6 @@ fun ReaderContent(
checkpoints: List,
showMenu: Boolean,
lockMenu: Boolean,
- readerPagination: Boolean,
- pages: List,
- currentPage: Int,
- onPageChanged: (Int) -> Unit,
contentPadding: PaddingValues,
verticalPadding: Dp,
horizontalGesture: ReaderHorizontalGesture,
@@ -115,7 +111,6 @@ fun ReaderContent(
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
- navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) {
ReaderBottomSheet(
@@ -125,33 +120,29 @@ fun ReaderContent(
)
if (isLoading || errorMessage == null) {
- ReaderScaffold(
- book = book,
- text = text,
- listState = listState,
- currentChapter = currentChapter,
- nestedScrollConnection = nestedScrollConnection,
- fastColorPresetChange = fastColorPresetChange,
- perceptionExpander = perceptionExpander,
- perceptionExpanderPadding = perceptionExpanderPadding,
- perceptionExpanderThickness = perceptionExpanderThickness,
- horizontalLimiter = horizontalLimiter,
- horizontalLimiterHeight = horizontalLimiterHeight,
- horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
- horizontalLimiterRuler = horizontalLimiterRuler,
- horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
- horizontalLimiterDimming = horizontalLimiterDimming,
- currentChapterProgress = currentChapterProgress,
- isLoading = isLoading,
- checkpoints = checkpoints,
- showMenu = showMenu,
- lockMenu = lockMenu,
- readerPagination = readerPagination,
- pages = pages,
- currentPage = currentPage,
- onPageChanged = onPageChanged,
- contentPadding = contentPadding,
- verticalPadding = verticalPadding,
+ ReaderScaffold(
+ book = book,
+ text = text,
+ listState = listState,
+ currentChapter = currentChapter,
+ nestedScrollConnection = nestedScrollConnection,
+ fastColorPresetChange = fastColorPresetChange,
+ perceptionExpander = perceptionExpander,
+ perceptionExpanderPadding = perceptionExpanderPadding,
+ perceptionExpanderThickness = perceptionExpanderThickness,
+ horizontalLimiter = horizontalLimiter,
+ horizontalLimiterHeight = horizontalLimiterHeight,
+ horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
+ horizontalLimiterRuler = horizontalLimiterRuler,
+ horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
+ horizontalLimiterDimming = horizontalLimiterDimming,
+ currentChapterProgress = currentChapterProgress,
+ isLoading = isLoading,
+ checkpoints = checkpoints,
+ showMenu = showMenu,
+ lockMenu = lockMenu,
+ contentPadding = contentPadding,
+ verticalPadding = verticalPadding,
horizontalGesture = horizontalGesture,
horizontalGestureScroll = horizontalGestureScroll,
horizontalGestureSensitivity = horizontalGestureSensitivity,
@@ -200,8 +191,7 @@ fun ReaderContent(
showSettingsBottomSheet = showSettingsBottomSheet,
showChaptersDrawer = showChaptersDrawer,
navigateBack = navigateBack,
- navigateToBookInfo = navigateToBookInfo,
- navigateToRsvp = navigateToRsvp
+ navigateToBookInfo = navigateToBookInfo
)
} else {
ReaderErrorPlaceholder(
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt
index f1efb53b..1527bc94 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderEffects.kt
@@ -19,7 +19,6 @@ import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen
import org.dueattendant149.bookshelf.presentation.reader.ReaderEffect
-import org.dueattendant149.bookshelf.presentation.rsvp.RsvpScreen
import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
@@ -184,10 +183,6 @@ fun ReaderEffects(
saveInBackStack = false
)
}
-
- is ReaderEffect.OnNavigateToRsvp -> {
- navigator.push(RsvpScreen(bookId = effect.bookId))
- }
}
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt
index c13c374d..fd94325f 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayout.kt
@@ -21,18 +21,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.TextMeasurer
-import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.coerceAtLeast
@@ -56,10 +51,6 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
@Composable
fun ReaderLayout(
text: List,
- pages: List,
- currentPage: Int,
- readerPagination: Boolean,
- onPageChanged: (Int) -> Unit,
listState: LazyListState,
contentPadding: PaddingValues,
verticalPadding: Dp,
@@ -106,9 +97,6 @@ fun ReaderLayout(
openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit
) {
val activity = LocalActivity.current
- val density = LocalDensity.current
- val textMeasurer = rememberTextMeasurer()
-
SelectionContainer(
onCopyRequested = {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
@@ -151,7 +139,7 @@ fun ReaderLayout(
.fillMaxSize()
.background(backgroundColor)
.then(
- if (!isLoading && toolbarHidden && !readerPagination) {
+ if (!isLoading && toolbarHidden) {
Modifier.noRippleClickable(
onClick = {
menuVisibility(
@@ -166,140 +154,90 @@ fun ReaderLayout(
)
.padding(contentPadding)
.padding(vertical = verticalPadding)
- .then(
- if (!readerPagination) {
- Modifier.readerHorizontalGesture(
- listState = listState,
- horizontalGesture = horizontalGesture,
- horizontalGestureScroll = horizontalGestureScroll,
- horizontalGestureSensitivity = horizontalGestureSensitivity,
- horizontalGestureAlphaAnim = horizontalGestureAlphaAnim,
- horizontalGesturePullAnim = horizontalGesturePullAnim,
- isLoading = isLoading
- )
- } else Modifier
+ .readerHorizontalGesture(
+ listState = listState,
+ horizontalGesture = horizontalGesture,
+ horizontalGestureScroll = horizontalGestureScroll,
+ horizontalGestureSensitivity = horizontalGestureSensitivity,
+ horizontalGestureAlphaAnim = horizontalGestureAlphaAnim,
+ horizontalGesturePullAnim = horizontalGesturePullAnim,
+ isLoading = isLoading
)
) {
- if (readerPagination && pages.isNotEmpty()) {
- ReaderPaginationLayout(
- pages = pages,
- currentPage = currentPage,
- contentPadding = PaddingValues(0.dp),
- verticalPadding = verticalPadding,
- highlightedReading = highlightedReading,
- highlightedReadingThickness = highlightedReadingThickness,
- progress = progress,
- progressBar = progressBar,
- progressBarPadding = progressBarPadding,
- progressBarAlignment = progressBarAlignment,
- progressBarFontSize = progressBarFontSize,
- paragraphHeight = paragraphHeight,
- sidePadding = sidePadding,
- bottomBarPadding = 0.dp,
- backgroundColor = backgroundColor,
- fontColor = fontColor,
- images = images,
- imagesCaptions = imagesCaptions,
- imagesCornersRoundness = imagesCornersRoundness,
- imagesAlignment = imagesAlignment,
- imagesWidth = imagesWidth,
- imagesColorEffects = imagesColorEffects,
- fontFamily = fontFamily,
- lineHeight = lineHeight,
- fontThickness = fontThickness,
- fontStyle = fontStyle,
- chapterTitleAlignment = chapterTitleAlignment,
- textAlignment = textAlignment,
- horizontalAlignment = horizontalAlignment,
- fontSize = fontSize,
- letterSpacing = letterSpacing,
- paragraphIndentation = paragraphIndentation,
- doubleClickTranslation = doubleClickTranslation,
- isLoading = isLoading,
- showMenu = showMenu,
- menuVisibility = menuVisibility,
- openShareApp = openShareApp,
- openWebBrowser = openWebBrowser,
- openTranslator = openTranslator,
- openDictionary = openDictionary,
- onPageChanged = onPageChanged
+ LazyColumnWithScrollbar(
+ state = listState,
+ enableScrollbar = false,
+ parentModifier = Modifier.weight(1f),
+ modifier = Modifier.fillMaxSize(),
+ userScrollEnabled = !horizontalGestureDisableScrolling,
+ contentPadding = PaddingValues(
+ top = (WindowInsets.displayCutout.asPaddingValues()
+ .calculateTopPadding() + paragraphHeight)
+ .coerceAtLeast(18.dp),
+ bottom = (WindowInsets.displayCutout.asPaddingValues()
+ .calculateBottomPadding() + paragraphHeight)
+ .coerceAtLeast(18.dp),
)
- } else {
- LazyColumnWithScrollbar(
- state = listState,
- enableScrollbar = false,
- parentModifier = Modifier.weight(1f),
- modifier = Modifier.fillMaxSize(),
- userScrollEnabled = !horizontalGestureDisableScrolling,
- contentPadding = PaddingValues(
- top = (WindowInsets.displayCutout.asPaddingValues()
- .calculateTopPadding() + paragraphHeight)
- .coerceAtLeast(18.dp),
- bottom = (WindowInsets.displayCutout.asPaddingValues()
- .calculateBottomPadding() + paragraphHeight)
- .coerceAtLeast(18.dp),
- )
- ) {
- itemsIndexed(
- text,
- key = { index, _ -> index }
- ) { index, entry ->
- val previousEntry = text.getOrNull(index - 1)
+ ) {
+ itemsIndexed(
+ text,
+ key = { index, _ -> index }
+ ) { index, entry ->
+ val previousEntry = text.getOrNull(index - 1)
- when {
- !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@itemsIndexed
- !imagesCaptions && previousEntry is ReaderText.Image -> return@itemsIndexed
- else -> {
- SpacedItem(
- index = index,
- spacing = paragraphHeight
- ) {
- ReaderLayoutText(
- showMenu = showMenu,
- entry = entry,
- imagesCornersRoundness = imagesCornersRoundness,
- imagesAlignment = imagesAlignment,
- imagesWidth = imagesWidth,
- imagesColorEffects = imagesColorEffects,
- fontFamily = fontFamily,
- fontColor = fontColor,
- lineHeight = lineHeight,
- fontThickness = fontThickness,
- fontStyle = fontStyle,
- chapterTitleAlignment = chapterTitleAlignment,
- textAlignment = textAlignment,
- horizontalAlignment = horizontalAlignment,
- fontSize = fontSize,
- letterSpacing = letterSpacing,
- sidePadding = sidePadding,
- paragraphIndentation = paragraphIndentation,
- doubleClickTranslation = doubleClickTranslation,
- highlightedReading = highlightedReading,
- highlightedReadingThickness = highlightedReadingThickness,
- toolbarHidden = toolbarHidden,
- openTranslator = openTranslator,
- menuVisibility = menuVisibility
- )
- }
+ when {
+ !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@itemsIndexed
+ !imagesCaptions && previousEntry is ReaderText.Image -> return@itemsIndexed
+ else -> {
+ SpacedItem(
+ index = index,
+ spacing = paragraphHeight
+ ) {
+ ReaderLayoutText(
+ showMenu = showMenu,
+ entry = entry,
+ imagesCornersRoundness = imagesCornersRoundness,
+ imagesAlignment = imagesAlignment,
+ imagesWidth = imagesWidth,
+ imagesColorEffects = imagesColorEffects,
+ fontFamily = fontFamily,
+ fontColor = fontColor,
+ lineHeight = lineHeight,
+ fontThickness = fontThickness,
+ fontStyle = fontStyle,
+ chapterTitleAlignment = chapterTitleAlignment,
+ textAlignment = textAlignment,
+ horizontalAlignment = horizontalAlignment,
+ fontSize = fontSize,
+ letterSpacing = letterSpacing,
+ sidePadding = sidePadding,
+ paragraphIndentation = paragraphIndentation,
+ doubleClickTranslation = doubleClickTranslation,
+ highlightedReading = highlightedReading,
+ highlightedReadingThickness = highlightedReadingThickness,
+ toolbarHidden = toolbarHidden,
+ openTranslator = openTranslator,
+ menuVisibility = menuVisibility
+ )
}
}
}
}
+ }
- AnimatedVisibility(
- visible = !showMenu && progressBar,
- enter = slideInVertically { it } + expandVertically(),
- exit = slideOutVertically { it } + shrinkVertically()
- ) {
- ReaderProgressBar(
- progress = progress,
- progressBarPadding = progressBarPadding,
- progressBarAlignment = progressBarAlignment,
- progressBarFontSize = progressBarFontSize,
- fontColor = fontColor,
- sidePadding = sidePadding
- )
- }
+ AnimatedVisibility(
+ visible = !showMenu && progressBar,
+ enter = slideInVertically { it } + expandVertically(),
+ exit = slideOutVertically { it } + shrinkVertically()
+ ) {
+ ReaderProgressBar(
+ progress = progress,
+ progressBarPadding = progressBarPadding,
+ progressBarAlignment = progressBarAlignment,
+ progressBarFontSize = progressBarFontSize,
+ fontColor = fontColor,
+ sidePadding = sidePadding
+ )
}
}
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt
index 705cec4f..808646bf 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutText.kt
@@ -6,6 +6,7 @@
package org.dueattendant149.bookshelf.ui.reader
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
@@ -22,7 +23,7 @@ import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
@Composable
-fun ReaderLayoutText(
+fun LazyItemScope.ReaderLayoutText(
showMenu: Boolean,
entry: ReaderText,
imagesCornersRoundness: Dp,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt
index b2d24f87..c34f99ed 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextChapter.kt
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -25,7 +26,7 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme
import org.dueattendant149.bookshelf.ui.common.components.common.StyledText
@Composable
-fun ReaderLayoutTextChapter(
+fun LazyItemScope.ReaderLayoutTextChapter(
chapter: Chapter,
chapterTitleAlignment: ReaderTextAlignment,
fontColor: Color,
@@ -35,6 +36,10 @@ fun ReaderLayoutTextChapter(
) {
Column(
Modifier
+ .animateItem(
+ fadeInSpec = null,
+ fadeOutSpec = null
+ )
.fillMaxWidth()
) {
Spacer(modifier = Modifier.height(22.dp))
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt
index 2fccc005..f6d5b1e9 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextImage.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -21,7 +22,7 @@ import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
@Composable
-fun ReaderLayoutTextImage(
+fun LazyItemScope.ReaderLayoutTextImage(
entry: ReaderText.Image,
sidePadding: Dp,
imagesCornersRoundness: Dp,
@@ -31,6 +32,10 @@ fun ReaderLayoutTextImage(
) {
Box(
modifier = Modifier
+ .animateItem(
+ fadeInSpec = null,
+ fadeOutSpec = null
+ )
.padding(horizontal = sidePadding)
.fillMaxWidth(),
contentAlignment = imagesAlignment.alignment
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt
index c69f4bbd..1320c793 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextParagraph.kt
@@ -10,12 +10,16 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.LineBreak
+import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Text
@@ -25,10 +29,9 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme
import org.dueattendant149.bookshelf.ui.common.components.common.StyledText
import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
-import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
@Composable
-fun ReaderLayoutTextParagraph(
+fun LazyItemScope.ReaderLayoutTextParagraph(
paragraph: Text,
showMenu: Boolean,
fontFamily: FontWithName,
@@ -49,20 +52,9 @@ fun ReaderLayoutTextParagraph(
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
) {
- val style = readerParagraphTextStyle(
- fontFamily = fontFamily,
- fontThickness = fontThickness,
- fontStyle = fontStyle,
- textAlignment = textAlignment,
- fontSize = fontSize,
- lineHeight = lineHeight,
- letterSpacing = letterSpacing,
- paragraphIndentation = paragraphIndentation,
- color = fontColor,
- )
-
Column(
modifier = Modifier
+ .animateItem(fadeInSpec = null, fadeOutSpec = null)
.fillMaxWidth()
.padding(horizontal = sidePadding),
verticalArrangement = Arrangement.Center,
@@ -92,7 +84,18 @@ fun ReaderLayoutTextParagraph(
)
} else Modifier
),
- style = style,
+ style = TextStyle(
+ fontFamily = fontFamily.font,
+ fontWeight = fontThickness.thickness,
+ textAlign = textAlignment.textAlignment,
+ textIndent = TextIndent(firstLine = paragraphIndentation),
+ fontStyle = fontStyle,
+ letterSpacing = letterSpacing,
+ fontSize = fontSize,
+ lineHeight = lineHeight,
+ color = fontColor,
+ lineBreak = LineBreak.Paragraph
+ ),
highlightText = highlightedReading,
highlightThickness = highlightedReadingThickness
)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt
index e630de25..be3220ee 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderLayoutTextSeparator.kt
@@ -7,6 +7,7 @@
package org.dueattendant149.bookshelf.ui.reader
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
@@ -17,13 +18,17 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
-fun ReaderLayoutTextSeparator(
+fun LazyItemScope.ReaderLayoutTextSeparator(
sidePadding: Dp,
fontColor: Color
) {
HorizontalDivider(
thickness = 3.dp,
modifier = Modifier
+ .animateItem(
+ fadeInSpec = null,
+ fadeOutSpec = null
+ )
.padding(horizontal = sidePadding)
.clip(CircleShape),
color = fontColor.copy(0.3f)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt
deleted file mode 100644
index 2fa65363..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderPaginationLayout.kt
+++ /dev/null
@@ -1,276 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.reader
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.PageSize.Fill
-import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.TextUnit
-import androidx.compose.ui.unit.dp
-import kotlinx.coroutines.launch
-import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
-import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
-import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent
-import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
-import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
-import org.dueattendant149.bookshelf.ui.common.components.common.SelectionContainer
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
-import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
-import org.dueattendant149.bookshelf.ui.common.helpers.showToast
-import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
-import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
-
-@Composable
-fun ReaderPaginationLayout(
- pages: List,
- currentPage: Int,
- contentPadding: PaddingValues,
- verticalPadding: Dp,
- highlightedReading: Boolean,
- highlightedReadingThickness: FontWeight,
- progress: String,
- progressBar: Boolean,
- progressBarPadding: Dp,
- progressBarAlignment: HorizontalAlignment,
- progressBarFontSize: TextUnit,
- paragraphHeight: Dp,
- sidePadding: Dp,
- bottomBarPadding: Dp,
- backgroundColor: Color,
- fontColor: Color,
- images: Boolean,
- imagesCaptions: Boolean,
- imagesCornersRoundness: Dp,
- imagesAlignment: HorizontalAlignment,
- imagesWidth: Float,
- imagesColorEffects: ColorFilter?,
- fontFamily: FontWithName,
- lineHeight: TextUnit,
- fontThickness: ReaderFontThickness,
- fontStyle: FontStyle,
- chapterTitleAlignment: ReaderTextAlignment,
- textAlignment: ReaderTextAlignment,
- horizontalAlignment: Alignment.Horizontal,
- fontSize: TextUnit,
- letterSpacing: TextUnit,
- paragraphIndentation: TextUnit,
- doubleClickTranslation: Boolean,
- isLoading: Boolean,
- showMenu: Boolean,
- menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit,
- openShareApp: (ReaderEvent.OnOpenShareApp) -> Unit,
- openWebBrowser: (ReaderEvent.OnOpenWebBrowser) -> Unit,
- openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
- openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit,
- onPageChanged: (Int) -> Unit,
-) {
- if (pages.isEmpty()) return
-
- val pagerState = rememberPagerState(
- initialPage = currentPage.coerceIn(0, pages.lastIndex),
- pageCount = { pages.size }
- )
- val scope = rememberCoroutineScope()
- val activity = LocalActivity.current
-
- LaunchedEffect(pagerState.currentPage) {
- onPageChanged(pagerState.currentPage)
- }
-
- LaunchedEffect(currentPage) {
- if (currentPage != pagerState.currentPage && currentPage in pages.indices) {
- pagerState.animateScrollToPage(currentPage)
- }
- }
-
- SelectionContainer(
- onCopyRequested = {
- activity.getString(org.dueattendant149.bookshelf.R.string.copied)
- .showToast(context = activity, longToast = false)
- },
- onShareRequested = { textToShare ->
- openShareApp(ReaderEvent.OnOpenShareApp(textToShare = textToShare))
- },
- onWebSearchRequested = { textToSearch ->
- openWebBrowser(ReaderEvent.OnOpenWebBrowser(textToSearch = textToSearch))
- },
- onTranslateRequested = { textToTranslate ->
- openTranslator(
- ReaderEvent.OnOpenTranslator(
- textToTranslate = textToTranslate,
- translateWholeParagraph = false
- )
- )
- },
- onDictionaryRequested = { textToDefine ->
- openDictionary(ReaderEvent.OnOpenDictionary(textToDefine = textToDefine))
- }
- ) { toolbarHidden ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .background(backgroundColor)
- .then(
- if (!isLoading && toolbarHidden) {
- Modifier.noRippleClickable(
- onClick = {
- menuVisibility(
- ReaderEvent.OnMenuVisibility(
- show = !showMenu,
- saveCheckpoint = true
- )
- )
- }
- )
- } else Modifier
- )
- .padding(contentPadding)
- .padding(vertical = verticalPadding)
- ) {
- HorizontalPager(
- state = pagerState,
- pageSize = Fill,
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f)
- ) { pageIndex ->
- ReaderPageContent(
- page = pages[pageIndex],
- verticalPadding = paragraphHeight,
- sidePadding = sidePadding,
- backgroundColor = backgroundColor,
- fontColor = fontColor,
- images = images,
- imagesCornersRoundness = imagesCornersRoundness,
- imagesAlignment = imagesAlignment,
- imagesWidth = imagesWidth,
- imagesColorEffects = imagesColorEffects,
- fontFamily = fontFamily,
- lineHeight = lineHeight,
- fontThickness = fontThickness,
- fontStyle = fontStyle,
- chapterTitleAlignment = chapterTitleAlignment,
- textAlignment = textAlignment,
- horizontalAlignment = horizontalAlignment,
- fontSize = fontSize,
- letterSpacing = letterSpacing,
- paragraphIndentation = paragraphIndentation,
- highlightedReading = highlightedReading,
- highlightedReadingThickness = highlightedReadingThickness,
- toolbarHidden = toolbarHidden,
- doubleClickTranslation = doubleClickTranslation,
- showMenu = showMenu,
- openTranslator = openTranslator,
- menuVisibility = menuVisibility
- )
- }
-
- if (!showMenu && progressBar) {
- ReaderProgressBar(
- progress = progress,
- progressBarPadding = progressBarPadding,
- progressBarAlignment = progressBarAlignment,
- progressBarFontSize = progressBarFontSize,
- fontColor = fontColor,
- sidePadding = sidePadding
- )
- }
- }
- }
-}
-
-@Composable
-private fun ReaderPageContent(
- page: ReaderPage,
- verticalPadding: Dp,
- sidePadding: Dp,
- backgroundColor: Color,
- fontColor: Color,
- images: Boolean,
- imagesCornersRoundness: Dp,
- imagesAlignment: HorizontalAlignment,
- imagesWidth: Float,
- imagesColorEffects: ColorFilter?,
- fontFamily: FontWithName,
- lineHeight: TextUnit,
- fontThickness: ReaderFontThickness,
- fontStyle: FontStyle,
- chapterTitleAlignment: ReaderTextAlignment,
- textAlignment: ReaderTextAlignment,
- horizontalAlignment: Alignment.Horizontal,
- fontSize: TextUnit,
- letterSpacing: TextUnit,
- paragraphIndentation: TextUnit,
- highlightedReading: Boolean,
- highlightedReadingThickness: FontWeight,
- toolbarHidden: Boolean,
- doubleClickTranslation: Boolean,
- showMenu: Boolean,
- openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
- menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit,
-) {
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(vertical = verticalPadding, horizontal = sidePadding),
- verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top),
- horizontalAlignment = horizontalAlignment
- ) {
- page.items.forEachIndexed { index, entry ->
- val previousEntry = page.items.getOrNull(index - 1)
- when {
- !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@forEachIndexed
- !images && previousEntry is ReaderText.Image -> return@forEachIndexed
- else -> {
- ReaderLayoutText(
- showMenu = showMenu,
- entry = entry,
- imagesCornersRoundness = imagesCornersRoundness,
- imagesAlignment = imagesAlignment,
- imagesWidth = imagesWidth,
- imagesColorEffects = imagesColorEffects,
- fontFamily = fontFamily,
- fontColor = fontColor,
- lineHeight = lineHeight,
- fontThickness = fontThickness,
- fontStyle = fontStyle,
- chapterTitleAlignment = chapterTitleAlignment,
- textAlignment = textAlignment,
- horizontalAlignment = horizontalAlignment,
- fontSize = fontSize,
- letterSpacing = letterSpacing,
- sidePadding = 0.dp,
- paragraphIndentation = paragraphIndentation,
- doubleClickTranslation = doubleClickTranslation,
- highlightedReading = highlightedReading,
- highlightedReadingThickness = highlightedReadingThickness,
- toolbarHidden = toolbarHidden,
- openTranslator = openTranslator,
- menuVisibility = menuVisibility
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt
index c5bd2c03..31bd411e 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderScaffold.kt
@@ -62,13 +62,8 @@ fun ReaderScaffold(
checkpoints: List,
showMenu: Boolean,
lockMenu: Boolean,
- readerPagination: Boolean,
- pages: List,
- currentPage: Int,
- onPageChanged: (Int) -> Unit,
contentPadding: PaddingValues,
verticalPadding: Dp,
- bottomBarPagination: Boolean = readerPagination,
horizontalGesture: ReaderHorizontalGesture,
horizontalGestureScroll: Float,
horizontalGestureSensitivity: Dp,
@@ -117,7 +112,6 @@ fun ReaderScaffold(
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
- navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) {
Scaffold(
@@ -143,8 +137,7 @@ fun ReaderScaffold(
showSettingsBottomSheet = showSettingsBottomSheet,
showChaptersDrawer = showChaptersDrawer,
navigateBack = navigateBack,
- navigateToBookInfo = navigateToBookInfo,
- navigateToRsvp = navigateToRsvp
+ navigateToBookInfo = navigateToBookInfo
)
}
},
@@ -163,10 +156,6 @@ fun ReaderScaffold(
lockMenu = lockMenu,
checkpoints = checkpoints,
bottomBarPadding = bottomBarPadding,
- readerPagination = readerPagination,
- pages = pages,
- currentPage = currentPage,
- onChangePage = onPageChanged,
restoreCheckpoint = restoreCheckpoint,
scroll = scroll,
changeProgress = changeProgress
@@ -176,10 +165,6 @@ fun ReaderScaffold(
) {
ReaderLayout(
text = text,
- pages = pages,
- currentPage = currentPage,
- readerPagination = readerPagination,
- onPageChanged = onPageChanged,
listState = listState,
contentPadding = contentPadding,
verticalPadding = verticalPadding,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt
deleted file mode 100644
index 7d0ce7ac..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTextStyle.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.reader
-
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.PlatformTextStyle
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontStyle
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.LineBreak
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.style.TextIndent
-import androidx.compose.ui.unit.TextUnit
-import androidx.compose.ui.unit.sp
-import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
-import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
-import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
-
-/**
- * Builds a [TextStyle] that is used both for measuring text during pagination
- * and for rendering paragraphs in the reader. Keeping the two in sync is
- * essential; any difference will cause page breaks to drift from the actual
- * rendered layout.
- */
-fun readerParagraphTextStyle(
- fontFamily: FontWithName,
- fontThickness: ReaderFontThickness,
- fontStyle: FontStyle,
- textAlignment: ReaderTextAlignment,
- fontSize: TextUnit,
- lineHeight: TextUnit,
- letterSpacing: TextUnit,
- paragraphIndentation: TextUnit,
- color: Color = Color.Unspecified,
- lineBreak: LineBreak = LineBreak.Paragraph,
- includeFontPadding: Boolean = false,
-): TextStyle {
- return TextStyle(
- fontFamily = fontFamily.font,
- fontWeight = fontThickness.thickness,
- textAlign = textAlignment.textAlignment,
- textIndent = TextIndent(firstLine = paragraphIndentation),
- fontStyle = fontStyle,
- letterSpacing = letterSpacing,
- fontSize = fontSize,
- lineHeight = lineHeight,
- color = color,
- lineBreak = lineBreak,
- platformStyle = PlatformTextStyle(includeFontPadding = includeFontPadding),
- )
-}
-
-/**
- * Builds a [TextStyle] for measuring chapter titles. It intentionally mirrors
- * the rendering style used by [ReaderLayoutTextChapter] as closely as possible
- * without accessing MaterialTheme at the call site.
- */
-fun readerChapterTextStyle(
- nested: Boolean,
- textAlignment: ReaderTextAlignment,
- color: Color = Color.Unspecified,
-): TextStyle {
- // Mirror Material3 headlineMedium / headlineSmall sizing. These are the
- // default Material3 values; they may differ slightly from a customized
- // theme, but they are accurate enough for pagination.
- val fontSize = if (nested) 24.sp else 28.sp
- val lineHeight = if (nested) 32.sp else 36.sp
- return TextStyle(
- fontSize = fontSize,
- lineHeight = lineHeight,
- fontWeight = FontWeight.Normal,
- textAlign = textAlignment.textAlignment,
- color = color,
- platformStyle = PlatformTextStyle(includeFontPadding = false),
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt
index 587bce24..fb6a3d9e 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/ReaderTopBar.kt
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
-import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -54,7 +53,6 @@ fun ReaderTopBar(
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
- navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) {
val activity = LocalActivity.current
@@ -140,15 +138,6 @@ fun ReaderTopBar(
}
}
- IconButton(
- icon = Icons.Default.Bolt,
- contentDescription = R.string.rsvp_open_from_reader_desc,
- disableOnClick = false,
- enabled = !lockMenu
- ) {
- navigateToRsvp(ReaderEvent.OnNavigateToRsvp(book.id))
- }
-
IconButton(
icon = Icons.Default.Settings,
contentDescription = R.string.open_reader_settings_content_desc,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt
deleted file mode 100644
index 080b1951..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/reader/VolumeKeyWindowCallback.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package org.dueattendant149.bookshelf.ui.reader
-
-import android.view.ActionMode
-import android.view.KeyEvent
-import android.view.Menu
-import android.view.MenuItem
-import android.view.MotionEvent
-import android.view.View
-import android.view.Window
-import android.view.WindowManager
-import android.view.SearchEvent
-import android.view.accessibility.AccessibilityEvent
-
-class VolumeKeyWindowCallback(
- private val delegate: Window.Callback?,
- private val onKeyEvent: (KeyEvent) -> Boolean,
-) : Window.Callback {
-
- override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- if (onKeyEvent(event)) return true
- return delegate?.dispatchKeyEvent(event) ?: false
- }
-
- override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean = delegate?.dispatchKeyShortcutEvent(event) ?: false
- override fun dispatchTouchEvent(event: MotionEvent): Boolean = delegate?.dispatchTouchEvent(event) ?: false
- override fun dispatchTrackballEvent(event: MotionEvent): Boolean = delegate?.dispatchTrackballEvent(event) ?: false
- override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean = delegate?.dispatchGenericMotionEvent(event) ?: false
- override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = delegate?.dispatchPopulateAccessibilityEvent(event) ?: false
- override fun onCreatePanelView(featureId: Int): View? = delegate?.onCreatePanelView(featureId)
- override fun onCreatePanelMenu(featureId: Int, menu: Menu): Boolean = delegate?.onCreatePanelMenu(featureId, menu) ?: false
- override fun onPreparePanel(featureId: Int, view: View?, menu: Menu): Boolean = delegate?.onPreparePanel(featureId, view, menu) ?: false
- override fun onMenuOpened(featureId: Int, menu: Menu): Boolean = delegate?.onMenuOpened(featureId, menu) ?: false
- override fun onMenuItemSelected(featureId: Int, item: MenuItem): Boolean = delegate?.onMenuItemSelected(featureId, item) ?: false
- override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams) { delegate?.onWindowAttributesChanged(attrs) }
- override fun onContentChanged() { delegate?.onContentChanged() }
- override fun onWindowFocusChanged(hasFocus: Boolean) { delegate?.onWindowFocusChanged(hasFocus) }
- override fun onWindowStartingActionMode(callback: ActionMode.Callback?): ActionMode? = delegate?.onWindowStartingActionMode(callback)
- override fun onWindowStartingActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? = delegate?.onWindowStartingActionMode(callback, type)
- override fun onActionModeStarted(mode: ActionMode) { delegate?.onActionModeStarted(mode) }
- override fun onActionModeFinished(mode: ActionMode) { delegate?.onActionModeFinished(mode) }
- override fun onPanelClosed(featureId: Int, menu: Menu) { delegate?.onPanelClosed(featureId, menu) }
- override fun onSearchRequested(): Boolean = delegate?.onSearchRequested() ?: false
- override fun onSearchRequested(event: SearchEvent?): Boolean = delegate?.onSearchRequested(event) ?: false
- override fun onAttachedToWindow() { delegate?.onAttachedToWindow() }
- override fun onDetachedFromWindow() { delegate?.onDetachedFromWindow() }
- override fun onPointerCaptureChanged(hasCapture: Boolean) { delegate?.onPointerCaptureChanged(hasCapture) }
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt
deleted file mode 100644
index c945006f..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/rsvp/RsvpContent.kt
+++ /dev/null
@@ -1,420 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.rsvp
-
-import android.view.WindowManager
-import androidx.activity.compose.BackHandler
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Close
-import androidx.compose.material.icons.filled.FastForward
-import androidx.compose.material.icons.filled.FastRewind
-import androidx.compose.material.icons.filled.Pause
-import androidx.compose.material.icons.filled.PlayArrow
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.staticCompositionLocalOf
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.rememberTextMeasurer
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.withStyle
-import androidx.compose.ui.unit.Constraints
-import androidx.compose.ui.unit.Dp
-import androidx.compose.ui.unit.TextUnit
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpToken
-import org.dueattendant149.bookshelf.presentation.rsvp.RsvpEvent
-import org.dueattendant149.bookshelf.presentation.rsvp.RsvpModel
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
-import kotlin.math.roundToInt
-
-private val LocalRsvpContainerWidth = staticCompositionLocalOf { 360.dp }
-
-private const val RSVP_MIN_FONT_SP = 16
-
-@Composable
-private fun computeRsvpFontSize(
- word: String,
- maxFontSize: TextUnit,
- containerWidth: Dp,
- paddingDp: Dp,
-): TextUnit {
- val density = androidx.compose.ui.platform.LocalDensity.current
- val textMeasurer = rememberTextMeasurer()
- val availablePx = with(density) { (containerWidth - paddingDp * 2).toPx() }.coerceAtLeast(1f)
-
- // Binary search the largest font size that still fits.
- var low = RSVP_MIN_FONT_SP.toFloat()
- var high = maxFontSize.value
- var best = low
- val textStyle = TextStyle(
- fontSize = 1.sp,
- fontFamily = FontFamily.Monospace,
- fontWeight = FontWeight.Normal,
- )
- repeat(10) {
- val mid = (low + high) / 2f
- val width = textMeasurer.measure(
- text = word,
- style = textStyle.copy(fontSize = mid.sp),
- constraints = Constraints(),
- ).size.width.toFloat()
- if (width <= availablePx) {
- best = mid
- low = mid
- } else {
- high = mid
- }
- }
- return if (best.sp.value < maxFontSize.value) best.sp else maxFontSize
-}
-
-@Composable
-fun RsvpContent(model: RsvpModel) {
- val state by model.state.collectAsStateWithLifecycle()
- val engineState by model.engineState.collectAsStateWithLifecycle()
- val settings = LocalSettings.current
- val activity = LocalActivity.current
-
- DisposableEffect(Unit) {
- activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- onDispose {
- activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- }
- }
-
- BackHandler(enabled = true) {
- model.onEvent(RsvpEvent.OnClose)
- }
-
- val token = engineState.currentToken
- val tokenCount = engineState.tokens.size.coerceAtLeast(1)
- val progress = engineState.progress
- val wpm = engineState.wpm.coerceAtLeast(1)
- val remainingMinutes = remember(engineState.currentIndex, tokenCount, wpm) {
- (((tokenCount - engineState.currentIndex).coerceAtLeast(1)).toFloat() / wpm.toFloat())
- .roundToInt()
- .coerceAtLeast(1)
- }
-
- val screenWidth = LocalConfiguration.current.screenWidthDp.dp
-
- Scaffold(
- containerColor = MaterialTheme.colorScheme.surface,
- contentColor = MaterialTheme.colorScheme.onSurface,
- ) { padding ->
- CompositionLocalProvider(LocalRsvpContainerWidth provides screenWidth) {
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- .pointerInput(Unit) {
- detectTapGestures(
- onTap = {
- model.onEvent(
- if (engineState.isPlaying) RsvpEvent.OnPlayPause
- else RsvpEvent.OnToggleControls
- )
- },
- onDoubleTap = { model.onEvent(RsvpEvent.OnPlayPause) }
- )
- }
- ) {
- when {
- state.isLoading -> {
- Column(
- modifier = Modifier.align(Alignment.Center),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- CircularProgressIndicator()
- Spacer(Modifier.height(16.dp))
- Text(
- text = stringResource(R.string.rsvp_loading),
- style = MaterialTheme.typography.bodyLarge
- )
- }
- }
-
- state.errorMessage != null -> {
- Text(
- text = state.errorMessage ?: stringResource(R.string.rsvp_no_text),
- modifier = Modifier.align(Alignment.Center),
- color = MaterialTheme.colorScheme.error
- )
- }
-
- token != null -> {
- WordStage(
- token = token,
- fontSizeSp = settings.rsvpFontSize.value.coerceIn(20, 96),
- showFocusGuide = settings.rsvpShowFocusGuide.value,
- progress = progress,
- currentIndex = engineState.currentIndex,
- tokenCount = tokenCount,
- remainingMinutes = remainingMinutes,
- modifier = Modifier.align(Alignment.Center)
- )
-
- AnimatedVisibility(
- visible = state.showControls || !engineState.isPlaying,
- enter = fadeIn(),
- exit = fadeOut(),
- modifier = Modifier.align(Alignment.BottomCenter)
- ) {
- ControlsBar(
- isPlaying = engineState.isPlaying,
- wpm = wpm,
- onPlayPause = { model.onEvent(RsvpEvent.OnPlayPause) },
- onSkipBack = { model.onEvent(RsvpEvent.OnSkip(-10)) },
- onSkipForward = { model.onEvent(RsvpEvent.OnSkip(10)) },
- onWpmChange = { model.onEvent(RsvpEvent.OnWpmChange(it)) },
- onClose = { model.onEvent(RsvpEvent.OnClose) },
- onSeek = { model.onEvent(RsvpEvent.OnSeekToProgress(it)) },
- progress = progress
- )
- }
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun WordStage(
- token: RsvpToken,
- fontSizeSp: Int,
- showFocusGuide: Boolean,
- progress: Float,
- currentIndex: Int,
- tokenCount: Int,
- remainingMinutes: Int,
- modifier: Modifier = Modifier,
-) {
- val pivotColor = MaterialTheme.colorScheme.primary
- val bodyColor = MaterialTheme.colorScheme.onSurface
-
- Column(
- modifier = modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- // Top progress bar
- Slider(
- value = progress,
- onValueChange = {},
- enabled = false,
- modifier = Modifier.fillMaxWidth()
- )
-
- Spacer(Modifier.height(40.dp))
-
- // Focus guide top
- if (showFocusGuide) {
- Text(
- text = stringResource(R.string.rsvp_focus_guide_top),
- color = pivotColor.copy(alpha = 0.5f),
- fontSize = 18.sp
- )
- Spacer(Modifier.height(4.dp))
- }
-
- // Word with pivot highlight, auto-shrunk and centered
- WordText(
- token = token,
- fontSizeSp = fontSizeSp,
- containerWidth = LocalRsvpContainerWidth.current,
- bodyColor = bodyColor,
- pivotColor = pivotColor
- )
-
-
- // Focus guide bottom
- if (showFocusGuide) {
- Spacer(Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.rsvp_focus_guide_bottom),
- color = pivotColor.copy(alpha = 0.5f),
- fontSize = 18.sp
- )
- }
-
- Spacer(Modifier.height(40.dp))
-
- // Stats
- Text(
- text = stringResource(R.string.rsvp_progress_label, currentIndex + 1, tokenCount),
- color = bodyColor.copy(alpha = 0.6f),
- style = MaterialTheme.typography.bodyMedium
- )
- Text(
- text = stringResource(R.string.rsvp_remaining_label, remainingMinutes.coerceAtLeast(0)),
- color = bodyColor.copy(alpha = 0.4f),
- style = MaterialTheme.typography.bodySmall
- )
- }
-}
-
-@Composable
-private fun ControlsBar(
- isPlaying: Boolean,
- wpm: Int,
- onPlayPause: () -> Unit,
- onSkipBack: () -> Unit,
- onSkipForward: () -> Unit,
- onWpmChange: (Int) -> Unit,
- onClose: () -> Unit,
- onSeek: (Float) -> Unit,
- progress: Float,
-) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- ) {
- // Seek slider (allows tapping to seek)
- Slider(
- value = progress,
- onValueChange = onSeek,
- modifier = Modifier.fillMaxWidth()
- )
-
- Spacer(Modifier.height(8.dp))
-
- Text(
- text = stringResource(R.string.rsvp_wpm_label, wpm),
- style = MaterialTheme.typography.labelLarge,
- modifier = Modifier.fillMaxWidth(),
- textAlign = TextAlign.Center
- )
-
- Slider(
- value = wpm.toFloat(),
- onValueChange = { onWpmChange(it.roundToInt()) },
- valueRange = 100f..900f,
- steps = 15,
- modifier = Modifier.fillMaxWidth()
- )
-
- Spacer(Modifier.height(8.dp))
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.SpaceEvenly,
- verticalAlignment = Alignment.CenterVertically
- ) {
- IconButton(onClick = onClose) {
- Icon(
- imageVector = Icons.Default.Close,
- contentDescription = stringResource(R.string.rsvp_back)
- )
- }
- IconButton(onClick = onSkipBack) {
- Icon(
- imageVector = Icons.Default.FastRewind,
- contentDescription = stringResource(R.string.rsvp_skip_back_10)
- )
- }
- IconButton(onClick = onPlayPause, modifier = Modifier.width(72.dp)) {
- Icon(
- imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
- contentDescription = stringResource(
- if (isPlaying) R.string.rsvp_pause else R.string.rsvp_play
- ),
- modifier = Modifier
- .fillMaxWidth()
- .height(48.dp)
- )
- }
- IconButton(onClick = onSkipForward) {
- Icon(
- imageVector = Icons.Default.FastForward,
- contentDescription = stringResource(R.string.rsvp_skip_forward_10)
- )
- }
- Spacer(Modifier.width(48.dp))
- }
- }
-}
-
-@Composable
-private fun WordText(
- token: RsvpToken,
- fontSizeSp: Int,
- containerWidth: Dp,
- bodyColor: Color,
- pivotColor: Color,
-) {
- val autoFontSize = computeRsvpFontSize(
- word = token.word,
- maxFontSize = fontSizeSp.sp,
- containerWidth = containerWidth,
- paddingDp = 32.dp,
- )
- val style = SpanStyle(
- fontSize = autoFontSize,
- fontFamily = FontFamily.Monospace,
- )
- val annotatedWord = buildAnnotatedString {
- withStyle(style.copy(color = bodyColor.copy(alpha = 0.55f), fontWeight = FontWeight.Normal)) {
- append(token.prefix)
- }
- withStyle(style.copy(color = pivotColor, fontWeight = FontWeight.Bold)) {
- append(token.pivot)
- }
- withStyle(style.copy(color = bodyColor.copy(alpha = 0.55f), fontWeight = FontWeight.Normal)) {
- append(token.suffix)
- }
- }
-
- Text(
- text = annotatedWord,
- maxLines = 1,
- softWrap = false,
- modifier = Modifier
- .fillMaxWidth()
- .height(((fontSizeSp * 1.6f).toInt()).dp),
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt
deleted file mode 100644
index 4fd399ab..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/search/SearchContent.kt
+++ /dev/null
@@ -1,328 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.search
-
-import android.content.Context
-import android.net.Uri
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.CloudOff
-import androidx.compose.material.icons.filled.Search
-import androidx.compose.material3.Button
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuAnchorType
-import androidx.compose.material3.ExposedDropdownMenuBox
-import androidx.compose.material3.ExposedDropdownMenuDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SnackbarHost
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.rememberVectorPainter
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse
-import org.dueattendant149.bookshelf.presentation.search.SearchModel
-import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
-import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
-import org.dueattendant149.bookshelf.ui.common.components.common.SearchTextField
-import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
-import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
-import java.io.File
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun SearchContent(model: SearchModel) {
- val state by model.state.collectAsStateWithLifecycle()
- val snackbarHostState = remember { SnackbarHostState() }
- val context = LocalContext.current
-
- var selectedUploadType by remember { mutableStateOf("ebook") }
- var uploadMenuExpanded by remember { mutableStateOf(false) }
-
- val filePicker = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.GetContent()
- ) { uri: Uri? ->
- uri ?: return@rememberLauncherForActivityResult
- val file = uriToFile(context, uri) ?: return@rememberLauncherForActivityResult
- val uploadType = inferUploadType(file.name) ?: selectedUploadType
- model.uploadBook(file, uploadType)
- }
-
- LaunchedEffect(state.downloadSuccess) {
- state.downloadSuccess?.let { title ->
- snackbarHostState.showSnackbar("Download started: $title")
- model.clearDownloadSuccess()
- }
- }
-
- LaunchedEffect(state.uploadSuccess) {
- state.uploadSuccess?.let { message ->
- snackbarHostState.showSnackbar(message)
- model.clearUploadSuccess()
- }
- }
-
- Scaffold(
- contentWindowInsets = WindowInsets(0),
- topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.search_screen)) }
- )
- },
- snackbarHost = { SnackbarHost(snackbarHostState) }
- ) { padding ->
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding)
- ) {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- item {
- SearchTextField(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 8.dp),
- initialQuery = state.query,
- onQueryChange = model::setQuery,
- onSearch = model::search
- )
-
- Button(
- onClick = { filePicker.launch("*/*") },
- enabled = !state.isUploading,
- shape = RoundedCornerShape(12.dp),
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp)
- ) {
- if (state.isUploading) {
- CircularProgressIndicator(
- modifier = Modifier.size(20.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onPrimary
- )
- Spacer(Modifier.width(8.dp))
- }
- Text("Загрузить в библиотеку")
- }
-
- ExposedDropdownMenuBox(
- expanded = uploadMenuExpanded,
- onExpandedChange = { uploadMenuExpanded = it },
- modifier = Modifier.fillMaxWidth()
- ) {
- OutlinedTextField(
- value = if (selectedUploadType == "audiobook") "Аудиокнига" else "Книга (EPUB/FB2/PDF)",
- onValueChange = {},
- readOnly = true,
- label = { Text("Тип загрузки") },
- trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = uploadMenuExpanded) },
- modifier = Modifier
- .fillMaxWidth()
- .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
- )
- ExposedDropdownMenu(
- expanded = uploadMenuExpanded,
- onDismissRequest = { uploadMenuExpanded = false }
- ) {
- DropdownMenuItem(
- text = { Text("Книга (EPUB/FB2/PDF)") },
- onClick = {
- selectedUploadType = "ebook"
- uploadMenuExpanded = false
- }
- )
- DropdownMenuItem(
- text = { Text("Аудиокнига") },
- onClick = {
- selectedUploadType = "audiobook"
- uploadMenuExpanded = false
- }
- )
- }
- }
- }
-
- if (state.isLoading) {
- item {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 32.dp),
- contentAlignment = Alignment.Center
- ) {
- CircularProgressIndicator()
- }
- }
- }
-
- itemsIndexed(
- items = state.results,
- key = { index, item -> "${item.guid}_$index" }
- ) { _, item ->
- val dlProgress = state.downloadProgresses[item.title]
- SearchResultCard(
- item = item,
- isDownloading = state.downloadingTitle == item.title,
- downloadProgress = dlProgress,
- onDownload = { model.startDownload(item) }
- )
- }
- }
-
- if (!state.isLoading && state.results.isEmpty() && state.query.isNotBlank() && state.error == null) {
- EmptyPlaceholder(
- modifier = Modifier.align(Alignment.Center),
- message = stringResource(R.string.search_field_empty),
- icon = rememberVectorPainter(Icons.Default.Search),
- actionTitle = null
- )
- }
-
- state.error?.let { error ->
- ErrorPlaceholder(
- modifier = Modifier.align(Alignment.Center),
- errorMessage = error,
- icon = rememberVectorPainter(Icons.Default.CloudOff),
- actionTitle = stringResource(R.string.bookshelf_retry_action),
- action = model::search
- )
- }
- }
- }
-}
-
-private fun inferUploadType(fileName: String?): String? {
- if (fileName == null) return null
- val name = fileName.lowercase()
- return when {
- AUDIOBOOK_EXTENSIONS.any { name.endsWith(it) } -> "audiobook"
- EBOOK_EXTENSIONS.any { name.endsWith(it) } -> "ebook"
- else -> null
- }
-}
-
-private val AUDIOBOOK_EXTENSIONS = listOf(".mp3", ".m4b", ".m4a", ".flac", ".ogg", ".aac", ".mp4")
-private val EBOOK_EXTENSIONS = listOf(".epub", ".fb2", ".pdf", ".mobi")
-
-private fun uriToFile(context: Context, uri: Uri): File? {
- return runCatching {
- context.contentResolver.openInputStream(uri)?.use { input ->
- val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
- val extension = inferExtensionFromMime(mime)
- ?: inferExtensionFromUriPath(uri.lastPathSegment)
- ?: ""
- File(context.cacheDir, "upload$extension").apply {
- outputStream().use { input.copyTo(it) }
- }
- }
- }.getOrNull()
-}
-
-private fun inferExtensionFromMime(mime: String): String? = when {
- mime.contains("epub") -> ".epub"
- mime.contains("pdf") -> ".pdf"
- mime.contains("fb2") -> ".fb2"
- mime.contains("mobi") -> ".mobi"
- mime.contains("audio") -> if (mime.contains("mp4") || mime.contains("m4b")) ".m4b" else ".mp3"
- else -> null
-}
-
-private fun inferExtensionFromUriPath(path: String?): String? {
- val name = path ?: "upload"
- return when {
- name.endsWith(".epub", ignoreCase = true) -> ".epub"
- name.endsWith(".fb2", ignoreCase = true) -> ".fb2"
- name.endsWith(".pdf", ignoreCase = true) -> ".pdf"
- name.endsWith(".mobi", ignoreCase = true) -> ".mobi"
- name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> ".m4b"
- name.endsWith(".mp3", ignoreCase = true) -> ".mp3"
- else -> null
- }
-}
-
-@Composable
-private fun SearchResultCard(
- item: SearchResultItemResponse,
- isDownloading: Boolean,
- downloadProgress: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse?,
- onDownload: () -> Unit,
-) {
- val progress = downloadProgress?.progress?.toFloat()?.coerceIn(0f, 1f) ?: 0f
- val description = buildString {
- append(item.source)
- append(" • ")
- append(item.format)
- if (item.sizeHuman.isNotBlank()) {
- append(" • ")
- append(item.sizeHuman)
- }
- }
-
- val book = org.dueattendant149.bookshelf.domain.model.library.Book(
- title = item.title,
- author = org.dueattendant149.bookshelf.core.ui.UIText.StringValue(item.author),
- description = description,
- filePath = "",
- coverImage = null,
- scrollIndex = 0,
- scrollOffset = 0,
- progress = progress,
- lastOpened = null,
- categories = emptyList(),
- )
-
- BookCard(
- book = book,
- progress = progress,
- primaryButton = {
- PrimaryActionButton(
- label = if (isDownloading) "…" else stringResource(R.string.bookshelf_download_action),
- enabled = !isDownloading,
- onClick = onDownload
- )
- }
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt
index d27b6e7c..f8a2e12f 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsContent.kt
@@ -16,7 +16,6 @@ import androidx.compose.runtime.Composable
fun SettingsContent(
listState: LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
- isRoot: Boolean = false,
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
@@ -29,7 +28,6 @@ fun SettingsContent(
SettingsScaffold(
listState = listState,
scrollBehavior = scrollBehavior,
- isRoot = isRoot,
navigateToGeneralSettings = navigateToGeneralSettings,
navigateToAppearanceSettings = navigateToAppearanceSettings,
navigateToReaderSettings = navigateToReaderSettings,
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt
index 108ff2e4..015a9164 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsScaffold.kt
@@ -24,7 +24,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
fun SettingsScaffold(
listState: LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
- isRoot: Boolean = false,
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
@@ -43,7 +42,6 @@ fun SettingsScaffold(
topBar = {
SettingsTopBar(
scrollBehavior = scrollBehavior,
- isRoot = isRoot,
navigateToStart = navigateToStart,
navigateBack = navigateBack
)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt
index 8e03826b..517833be 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/SettingsTopBar.kt
@@ -24,7 +24,6 @@ import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
@Composable
fun SettingsTopBar(
scrollBehavior: TopAppBarScrollBehavior,
- isRoot: Boolean = false,
navigateToStart: () -> Unit,
navigateBack: () -> Unit
) {
@@ -33,11 +32,9 @@ fun SettingsTopBar(
StyledText(stringResource(id = R.string.settings_screen))
},
navigationIcon = {
- if (!isRoot) {
- NavigatorBackIconButton(
- navigateBack = navigateBack
- )
- }
+ NavigatorBackIconButton(
+ navigateBack = navigateBack
+ )
},
actions = {
IconButton(
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt
index c07c7e73..d3dc34fb 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt
@@ -73,7 +73,7 @@ fun BookshelfSettingsContent(
value = state.bookshelfUrl,
onValueChange = model::updateBookshelfUrl,
label = { Text(stringResource(R.string.bookshelf_url_option)) },
- placeholder = { Text("https://tts.dueattendant149.org") },
+ placeholder = { Text("http://bookshelf-api:8073") },
supportingText = { Text(stringResource(R.string.bookshelf_url_option_desc)) },
modifier = Modifier.fillMaxWidth()
)
@@ -83,7 +83,7 @@ fun BookshelfSettingsContent(
value = state.absUrl,
onValueChange = model::updateAbsUrl,
label = { Text(stringResource(R.string.abs_url_option)) },
- placeholder = { Text("https://books.dueattendant149.org") },
+ placeholder = { Text("http://192.168.1.119:13378") },
supportingText = { Text(stringResource(R.string.abs_url_option_desc)) },
modifier = Modifier.fillMaxWidth()
)
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt
index a65d5ad1..706f3f16 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/ReadingModeSubcategory.kt
@@ -21,7 +21,6 @@ import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGesturePullAnimOption
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureScrollOption
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureSensitivityOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.ReaderPaginationOption
fun LazyListScope.ReadingModeSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
@@ -35,10 +34,6 @@ fun LazyListScope.ReadingModeSubcategory(
showTitle = showTitle,
showDivider = showDivider
) {
- item {
- ReaderPaginationOption()
- }
-
item {
HorizontalGestureOption()
}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt
deleted file mode 100644
index f409b042..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_mode/components/ReaderPaginationOption.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.ui.common.components.settings.SwitchWithTitle
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
-
-@Composable
-fun ReaderPaginationOption() {
- val settings = LocalSettings.current
- val selected = settings.readerPagination.value
-
- SwitchWithTitle(
- title = stringResource(id = R.string.reader_pagination_option),
- description = stringResource(id = R.string.reader_pagination_option_desc),
- selected = selected,
- onClick = {
- settings.readerPagination.update(!selected)
- }
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt
index 0e445da1..17e5ad84 100644
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt
+++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/ReadingSpeedSubcategory.kt
@@ -26,12 +26,6 @@ import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderOption
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderPaddingOption
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderThicknessOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpFocusGuideOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpFontSizeOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseChapterOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseLongWordsOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseParagraphOption
-import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpWpmOption
fun LazyListScope.ReadingSpeedSubcategory(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
@@ -88,29 +82,5 @@ fun LazyListScope.ReadingSpeedSubcategory(
item {
HorizontalLimiterDimmingOption()
}
-
- item {
- RsvpWpmOption()
- }
-
- item {
- RsvpFontSizeOption()
- }
-
- item {
- RsvpPauseParagraphOption()
- }
-
- item {
- RsvpPauseChapterOption()
- }
-
- item {
- RsvpPauseLongWordsOption()
- }
-
- item {
- RsvpFocusGuideOption()
- }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt
deleted file mode 100644
index f0254313..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpFontSizeOption.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.ui.common.components.settings.SliderWithTitle
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
-
-@Composable
-fun RsvpFontSizeOption() {
- val settings = LocalSettings.current
-
- SliderWithTitle(
- value = settings.rsvpFontSize.value to "sp",
- fromValue = 24,
- toValue = 96,
- title = stringResource(id = R.string.rsvp_font_size_option),
- onValueChange = { settings.rsvpFontSize.update(it) }
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt
deleted file mode 100644
index ad47fc49..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpPauseOptions.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.ui.common.components.settings.SwitchWithTitle
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
-
-@Composable
-fun RsvpPauseParagraphOption() {
- val settings = LocalSettings.current
- RsvpSwitchOption(
- selected = settings.rsvpPauseOnParagraph.value,
- titleRes = R.string.rsvp_pause_paragraph_option,
- descRes = R.string.rsvp_pause_paragraph_option_desc,
- onToggle = { settings.rsvpPauseOnParagraph.update(!settings.rsvpPauseOnParagraph.lastValue) },
- )
-}
-
-@Composable
-fun RsvpPauseChapterOption() {
- val settings = LocalSettings.current
- RsvpSwitchOption(
- selected = settings.rsvpPauseOnChapter.value,
- titleRes = R.string.rsvp_pause_chapter_option,
- descRes = R.string.rsvp_pause_chapter_option_desc,
- onToggle = { settings.rsvpPauseOnChapter.update(!settings.rsvpPauseOnChapter.lastValue) },
- )
-}
-
-@Composable
-fun RsvpPauseLongWordsOption() {
- val settings = LocalSettings.current
- RsvpSwitchOption(
- selected = settings.rsvpPauseOnLongWords.value,
- titleRes = R.string.rsvp_pause_long_words_option,
- descRes = R.string.rsvp_pause_long_words_option_desc,
- onToggle = { settings.rsvpPauseOnLongWords.update(!settings.rsvpPauseOnLongWords.lastValue) },
- )
-}
-
-@Composable
-fun RsvpFocusGuideOption() {
- val settings = LocalSettings.current
- RsvpSwitchOption(
- selected = settings.rsvpShowFocusGuide.value,
- titleRes = R.string.rsvp_focus_guide_option,
- descRes = R.string.rsvp_focus_guide_option_desc,
- onToggle = { settings.rsvpShowFocusGuide.update(!settings.rsvpShowFocusGuide.lastValue) },
- )
-}
-
-@Composable
-private fun RsvpSwitchOption(
- selected: Boolean,
- titleRes: Int,
- descRes: Int,
- onToggle: () -> Unit,
-) {
- SwitchWithTitle(
- selected = selected,
- title = stringResource(titleRes),
- description = stringResource(descRes),
- onClick = onToggle,
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt
deleted file mode 100644
index 393df5b1..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/reader/reading_speed/components/RsvpWpmOption.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.ui.common.components.settings.SliderWithTitle
-import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
-
-@Composable
-fun RsvpWpmOption() {
- val settings = LocalSettings.current
-
- SliderWithTitle(
- value = settings.rsvpWpm.value to stringResource(R.string.rsvp_wpm_label, settings.rsvpWpm.value),
- fromValue = 100,
- toValue = 900,
- title = stringResource(id = R.string.rsvp_wpm_option),
- onValueChange = { settings.rsvpWpm.update(it) }
- )
-}
diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt
deleted file mode 100644
index f36b7142..00000000
--- a/app/src/main/java/org/dueattendant149/bookshelf/ui/tts/TtsContent.kt
+++ /dev/null
@@ -1,247 +0,0 @@
-/*
- * Book's Story — free and open-source Material You eBook reader.
- * Copyright (C) 2024-2026 Acclorite
- * SPDX-License-Identifier: GPL-3.0-only
- */
-
-package org.dueattendant149.bookshelf.ui.tts
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material3.Button
-import androidx.compose.material3.Card
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.dueattendant149.bookshelf.R
-import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
-import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
-import org.dueattendant149.bookshelf.presentation.tts.TtsModel
-import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun TtsContent(
- model: TtsModel,
- bookRemoteId: String,
- navigateBack: () -> Unit,
-) {
- val state by model.state.collectAsStateWithLifecycle()
-
- LaunchedEffect(bookRemoteId) {
- model.load(bookRemoteId)
- }
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.tts_screen)) },
- navigationIcon = {
- NavigatorBackIconButton(navigateBack = navigateBack)
- }
- )
- }
- ) { padding ->
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding),
- contentPadding = PaddingValues(16.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- item {
- Text(
- text = stringResource(R.string.tts_book_id),
- style = MaterialTheme.typography.bodyMedium,
- )
- Text(
- text = bookRemoteId,
- style = MaterialTheme.typography.bodySmall,
- )
- }
-
- if (state.isLoading && state.engines.isEmpty()) {
- item {
- CircularProgressIndicator()
- }
- }
-
- state.error?.let { error ->
- item {
- Text(
- text = error,
- color = MaterialTheme.colorScheme.error,
- )
- }
- }
-
- item {
- Text(
- text = stringResource(R.string.tts_engine),
- style = MaterialTheme.typography.titleMedium,
- )
- }
-
- items(state.engines.size) { index ->
- val engine = state.engines[index]
- EngineItem(
- engine = engine,
- selected = state.selectedEngine?.id == engine.id,
- onClick = { model.selectEngine(engine) }
- )
- }
-
- item {
- Text(
- text = stringResource(R.string.tts_voice),
- style = MaterialTheme.typography.titleMedium,
- )
- }
-
- items(state.voices.size) { index ->
- val voice = state.voices[index]
- VoiceItem(
- voice = voice,
- selected = state.selectedVoice?.id == voice.id,
- onClick = { model.selectVoice(voice) }
- )
- }
-
- item {
- Text(
- text = stringResource(R.string.tts_speed),
- style = MaterialTheme.typography.titleMedium,
- )
- Text(
- text = stringResource(R.string.tts_speed_label, state.speed),
- style = MaterialTheme.typography.bodyMedium,
- )
- Slider(
- value = state.speed.toFloat(),
- onValueChange = { model.updateSpeed(it.toDouble()) },
- valueRange = 0.5f..2.0f,
- steps = 30,
- modifier = Modifier.fillMaxWidth()
- )
- }
-
- item {
- Button(
- onClick = { model.createJob(bookRemoteId) },
- enabled = state.selectedEngine != null &&
- state.selectedVoice != null &&
- !state.isCreatingJob &&
- state.job?.isRunning != true,
- modifier = Modifier.fillMaxWidth()
- ) {
- if (state.isCreatingJob) {
- CircularProgressIndicator()
- } else {
- Text(stringResource(R.string.tts_create_job))
- }
- }
- }
-
- state.job?.let { job ->
- item {
- Card(modifier = Modifier.fillMaxWidth()) {
- Text(
- text = stringResource(R.string.tts_status_label, job.status),
- style = MaterialTheme.typography.bodyLarge,
- modifier = Modifier.padding(16.dp)
- )
- LinearProgressIndicator(
- progress = { job.progress.toFloat().coerceIn(0f, 1f) },
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp),
- )
- Text(
- text = stringResource(
- R.string.tts_progress_label,
- job.completedChapters,
- job.totalChapters
- ),
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(16.dp)
- )
- }
- }
- }
-
- state.outputFilePath?.let { path ->
- item {
- Text(
- text = stringResource(R.string.tts_downloaded_label, path),
- style = MaterialTheme.typography.bodyMedium,
- )
- }
- }
- }
- }
-}
-
-@Composable
-private fun EngineItem(
- engine: TtsEngine,
- selected: Boolean,
- onClick: () -> Unit,
-) {
- ListItem(
- headlineContent = { Text(engine.name) },
- supportingContent = { Text(engine.id) },
- trailingContent = {
- if (selected) {
- Text(stringResource(R.string.tts_selected_label))
- }
- },
- modifier = Modifier.fillMaxWidth(),
- tonalElevation = if (selected) 4.dp else 0.dp,
- shadowElevation = if (selected) 2.dp else 0.dp,
- )
-}
-
-@Composable
-private fun VoiceItem(
- voice: TtsVoice,
- selected: Boolean,
- onClick: () -> Unit,
-) {
- ListItem(
- headlineContent = { Text(voice.name) },
- supportingContent = {
- Text(
- stringResource(
- R.string.tts_voice_description,
- voice.language,
- voice.engine
- )
- )
- },
- trailingContent = {
- if (selected) {
- Text(stringResource(R.string.tts_selected_label))
- }
- },
- modifier = Modifier.fillMaxWidth(),
- tonalElevation = if (selected) 4.dp else 0.dp,
- shadowElevation = if (selected) 2.dp else 0.dp,
- )
-}
diff --git a/app/src/main/res/drawable/audiobook_tab_filled.xml b/app/src/main/res/drawable/audiobook_tab_filled.xml
deleted file mode 100644
index 7d76067b..00000000
--- a/app/src/main/res/drawable/audiobook_tab_filled.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/audiobook_tab_outlined.xml b/app/src/main/res/drawable/audiobook_tab_outlined.xml
deleted file mode 100644
index 70ef3334..00000000
--- a/app/src/main/res/drawable/audiobook_tab_outlined.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/book_tab_filled.xml b/app/src/main/res/drawable/book_tab_filled.xml
deleted file mode 100644
index eb22138a..00000000
--- a/app/src/main/res/drawable/book_tab_filled.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/book_tab_outlined.xml b/app/src/main/res/drawable/book_tab_outlined.xml
deleted file mode 100644
index 34bbd309..00000000
--- a/app/src/main/res/drawable/book_tab_outlined.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml
deleted file mode 100644
index fdf1b759..00000000
--- a/app/src/main/res/drawable/ic_notification.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
-
diff --git a/app/src/main/res/drawable/search_tab_filled.xml b/app/src/main/res/drawable/search_tab_filled.xml
deleted file mode 100644
index 4c39a21b..00000000
--- a/app/src/main/res/drawable/search_tab_filled.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/search_tab_outlined.xml b/app/src/main/res/drawable/search_tab_outlined.xml
deleted file mode 100644
index 4c39a21b..00000000
--- a/app/src/main/res/drawable/search_tab_outlined.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/settings_tab_filled.xml b/app/src/main/res/drawable/settings_tab_filled.xml
deleted file mode 100644
index b57b97aa..00000000
--- a/app/src/main/res/drawable/settings_tab_filled.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/drawable/settings_tab_outlined.xml b/app/src/main/res/drawable/settings_tab_outlined.xml
deleted file mode 100644
index 69f70a88..00000000
--- a/app/src/main/res/drawable/settings_tab_outlined.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2307e75e..52c2b28e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,618 +6,552 @@
- KRait
- По умолчанию
-
-
- Воспроизведение аудио
- Показывает текущую аудиокнигу
- Нет трека
- -10с
- +30с
- Предыдущий
- Следующий
- Играть
- Пауза
+ Book\'s Story
+ Default
- Библиотека
- История
- Обзор
- Настройки
- О приложении
+ Library
+ History
+ Browse
+ Settings
+ About app
- Добавить книги?
- Удалить книги?
- Удалить книгу?
- Удалить историю чтения?
- Удалить категорию?
+ Add books?
+ Delete books?
+ Delete book?
+ Delete reading history?
+ Remove category?
- Вы можете выбрать книги, которые хотите добавить.
- Название и обложку можно изменить позже.
- Загрузка может занять несколько минут.
+ You can choose which books you want to add.
+ You can edit Title and Cover image of the book later.
+ The loading process can take up to several minutes.
- Это удалит все выбранные книги (%1$s) из базы данных.
- Файлы на устройстве не будут удалены.
+ It will delete all selected books(%1$s) from the database.
+ It will not delete selected books from your device.
- Это удалит книгу из базы данных.
- Файл на устройстве не будет удалён.
+ It will delete this book from the database.
+ It will not delete this book from your device.
- Это удалит всю историю из базы данных.
- После удаления порядок книг будет сброшен.
+ It will delete the whole history from the database.
+ After deleting the whole history, the order of the books will be reset.
- Категория будет удалена из библиотеки, но книги не будут удалены.
+ This will remove this category from the Library but will not delete any books.
- Нет описания.
- Что-то пошло не так.
- Приложение-переводчик не найдено.
- Словарь не найден.
- Браузер не найден.
- Нет приложения для отправки.
- Нет приложения для выбора папки.
+ No description.
+ Something went wrong.
+ No translator app found.
+ No dictionary app found.
+ No browser app found.
+ No app to share found.
+ No app to choose folder found.
- Файл пуст, отсутствует или недоступен.
- Проверьте файл, настройки сканирования или измените путь.
+ File is empty, missing, or inaccessible.
+ Verify the file, scan settings or change the path, then try again.
- Не удалось сбросить обложку.
- Закрытый исходный код
- Файл не существует или недоступен
- Поле не должно быть пустым.
+ Could not reset cover image.
+ Closed source
+ File does not exist or inaccessible
+ This field should not be empty.
- Поиск…
+ Search…
- Расширьте коллекцию книг, загрузив новые файлы
+ Expand the library\'s book collection with your downloads
- Читайте любимые книги и сортируйте их по категориям
+ Read your favorite books and sort them between categories
- Откройте последнюю книгу и продолжите чтение
+ Let your last book you read open the pages of a new great story of your reading journey
- Нет категорий.
+ No categories.
- %1$s выбрано
- Пресет %1$s
- Выбран «%1$s»
+ %1$s selected
+ Preset %1$s
+ Selected «%1$s»
- Отмена
- Добавить книгу
- Переместить
- Удалить
- Никогда
- Отменить
- Копировать
- Сообщить
- Перевести
- Словарь
- Далее
- Готово
- Поиск в сети
- Поделиться
- ОК
- Читать
- Изменить путь
- Добавить папку
- Изменить
- Создать категорию
- Настроить сканирование
+ Cancel
+ Add a book
+ Move
+ Delete
+ Never
+ Undo
+ Copy
+ Report
+ Translate
+ Dictionary
+ Next
+ Done
+ Web search
+ Share
+ OK
+ Read
+ Change path
+ Add folder
+ Edit
+ Create category
+ Set up scanning
- Неизвестен
- Сбросить обложку
- Сбросить обложку книги по умолчанию
- Изменить обложку
- Выбрать новую обложку из фото
- Удалить обложку
- Удалить текущую обложку книги
- Автор
+ Unknown
+ Reset cover image
+ Reset cover image of the book to the default
+ Change cover image
+ Choose new cover from your photos
+ Delete cover image
+ Delete current cover image of the book
+ Author
- По умолчанию
- Общие
- Отображение
- Цвета
- Фильтр
- Сортировка
- Читалка
+ Default
+ General
+ Display
+ Colors
+ Filter
+ Sort
+ Reader
- Общие
- Язык приложения
- Внешний вид
- Тема, цвета
- Читалка
- Шрифт, текст
- Обзор
- Сканирование, отображение
- Библиотека
- Категории, отображение, вкладки
- Книжная полка
- Сервер, ABS, TTS
+ General
+ App language
+ Appearance
+ Theme, colors
+ Reader
+ Font, text
+ Browse
+ Scan, display
+ Library
+ Categories, display, tabs
+ Bookshelf
+ Server, ABS, TTS
TTS
- Движок
- Голос
- ID книги
- Скорость
- Создать задачу
- Обновить задачи
- Задачи
- Задачи TTS
+ Engine
+ Voice
+ Book ID
+ Speed
+ Create job
+ Refresh jobs
+ Jobs
+ TTS jobs
TTS
- Сервер книжной полки
- URL Bookshelf API
- Базовый URL сервиса bookshelf-api
- URL Audiobookshelf
- Прямой URL сервера Audiobookshelf
- Токен ABS
- API-токен из настроек Audiobookshelf
- Сохранить
- Проверить соединение
- Получить ABS URL
- Загрузить библиотеки
- Соединение установлено
- Подключено к %1$d библиотекам
- Библиотеки
- %1$s • %2$d элементов
- Выбрано
- Книги в %1$s
- Удалённые библиотеки не найдены
- Обновить
- Повторить
+ Bookshelf server
+ Bookshelf API URL
+ Base URL of the bookshelf-api service
+ Audiobookshelf URL
+ Direct URL of the Audiobookshelf server
+ ABS token
+ API token from Audiobookshelf settings
+ Save
+ Check connection
+ Derive ABS URL
+ Fetch libraries
+ Connection successful
+ Connected to %1$d libraries
+ Libraries
+ %1$s • %2$d items
+ Selected
+ Books in %1$s
+ No remote libraries found
+ Refresh
+ Retry
- Шрифт
- Текст
- Переводчик
- Поля
- Прочее
- Скорость чтения
- Режим чтения
- Постраничный режим
- Перелистывание страниц вместо вертикальной прокрутки
- Система
- Главы
- Изображения
- Прогресс
- Настройки темы
- Цвета
- Сканирование
- Отображение
- Вкладки
- Категории
- Фильтр
- Сортировка
+ Font
+ Text
+ Translator
+ Margins
+ Misc
+ Reading speed
+ Reading mode
+ System
+ Chapters
+ Images
+ Progress
+ Theme preferences
+ Colors
+ Scan
+ Display
+ Tabs
+ Categories
+ Filter
+ Sort
- Эти папки используются для сканирования файлов в Обзоре.
- Удаление папки не удалит сами файлы или книги из Библиотеки.
- Чтобы отозвать доступ, нажмите соответствующую кнопку.
+ These folders are used to scan and retrieve files in the Browse.
+ Removing a folder here will not delete actual files or books from the Library.
+ To revoke access, click the corresponding button.
- Изначально книги добавляются в категорию по умолчанию,
- но могут принадлежать нескольким категориям, созданным здесь.
- Удаление категории не удалит книги из Библиотеки.
- Удерживайте и перетаскивайте иконку категории для изменения порядка.
+ Initially, books are added to the default category,
+ but can belong to several custom categories created here.
+ Removing a category here will not delete books from the Library.
+ Hold and move the category icon to rearrange the categories.
- Сортировку можно изменить в Библиотеке.
+ Sorting can be changed in the Library.
- Язык приложения
- Тёмная тема
- Полностью тёмная
- Абсолютно тёмная
- Сделать цвет фона полностью чёрным
- Контраст темы
- Тема приложения
- Двойное нажатие для выхода
- Нажмите «Назад» дважды для выхода
- Подписи навигации
- Показывать подписи под иконками навигации
+ App language
+ Dark theme
+ Pure dark
+ Absolute dark
+ Change background color to be completely black
+ Theme contrast
+ App theme
+ Double back press to exit
+ Press back twice to exit the app
+ Show navigation labels
+ Show navigation bar labels under icons
- Гарнитура шрифта
- Толщина шрифта
- Начертание шрифта
- Размер шрифта
- Межстрочный интервал
- Интервал между абзацами
- Отступ абзаца
- Цветовой пресет
- Цвет фона
- Цвет шрифта
- Боковые поля
- Вертикальные поля
- Перевод по двойному клику
- Двойной клик по абзацу для перевода
- Быстрая смена пресета
- Свайп верхней панели для смены пресета
- Выравнивание текста
- Выравнивание заголовка главы
- Межбуквенный интервал
- Отступ выреза
- Отступ для области выреза экрана
- Полноэкранный режим
- Скрыть системные панели и поля
- Не выключать экран
- Скрыть панели при быстрой прокрутке
- Тренажёр скорости
+ Font family
+ Font thickness
+ Font style
+ Font size
+ Line height
+ Paragraph spacing
+ Paragraph indentation
+ Color preset
+ Background color
+ Font color
+ Side margins
+ Vertical margins
+ Double click translation
+ Double click on paragraph to translate it
+ Fast color preset change
+ Swipe top bar left or right to change color preset
+ Text alignment
+ Chapter title alignment
+ Letter spacing
+ Cutout margin
+ Apply margin to the cutout area
+ Fullscreen
+ Hide system bars and margins
+ Keep screen on
+ Hide bars on fast scroll
+ Perception expander
- Увеличьте скорость чтения, фокусируясь на центре
+ Increase your reading speed by focusing on center
- Боковой отступ линии
- Толщина линии
- Горизонтальный ограничитель
+ Line side margin
+ Line thickness
+ Horizontal limiter
- Показывать линейку и затемнять нефокусируемую часть экрана
+ Show horizontal ruler and dim unfocused part of the screen
- Высота
- Вертикальное смещение
- Горизонтальная линейка
- Толщина линейки
- Затемнение
- Ориентация экрана
- Своя яркость
- Яркость
- Горизонтальный жест
- Доля прокрутки
- Чувствительность
- Анимация видимости
+ Height
+ Vertical offset
+ Horizontal ruler
+ Ruler thickness
+ Dimming
+ Screen orientation
+ Custom brightness
+ Brightness
+ Horizontal gesture
+ Scroll fraction
+ Sensitivity
+ Visibility animation
- Анимация видимости при горизонтальном жесте
+ Enable visibility animation when using horizontal gesture
- Анимация подтягивания
+ Pull animation
- Анимация подтягивания при горизонтальном жесте
+ Enable pull animation when using horizontal gesture
- Отключить прокрутку
- Полностью отключить прокрутку читалки
- Отступ нижней панели
- Подсветка слов
- Подсвечивать начало каждого слова
- Толщина подсветки
- Изображения
- Показывать изображения
- Подписи
- Показывать подписи под изображениями
- Скругление углов
- Выравнивание
- Размер
- Цветовые эффекты
- Индикатор прогресса
- Показывать индикатор прогресса внизу
- Отступы
- Выравнивание
- Размер шрифта
- Отсчёт прогресса
+ Disable scrolling
+ Completely disable reader scrolling
+ Bottom bar margin
+ Highlight words
+ Highlight the beginning of each word
+ Highlight thickness
+ Images
+ Display images
+ Captions
+ Display captions under images
+ Corners roundness
+ Alignment
+ Size
+ Color effects
+ Progress bar
+ Display progress bar at the bottom
+ Margins
+ Alignment
+ Font size
+ Progress count
- Показывать кнопку «Читать»
- Показывать прогресс
- Позиция заголовка
- Показывать количество книг
- Показывать вкладки категорий
- Всегда показывать вкладку по умолчанию
- Сортировка по категориям
+ Show «Read» button
+ Show progress
+ Title position
+ Show book count
+ Show category tabs
+ Always show default tab
+ Per-category sort
- Красный
- Зелёный
- Синий
- Название пресета…
+ Red
+ Green
+ Blue
+ Name a preset…
- Выключено
- Включено
- Как в системе
+ Disabled
+ Enabled
+ System
- Выключено
- Включено
- В режиме экономии
+ Disabled
+ Enabled
+ Saver
- Стандартный
- Средний
- Высокий
+ Standard
+ Medium
+ High
- Курсив
- Обычный
+ Italic
+ Normal
- Скрыт
- Снизу
- Внутри
+ Hidden
+ Below
+ Inside
- Название
+ Title
- Режим отображения
- Список
- Сетка
+ Display mode
+ List
+ Grid
- По алфавиту
- Формат файла
- Последнее изменение
- Размер файла
- Последнее чтение
- Прогресс
- Автор
+ Alphabetically
+ File format
+ Last modified
+ File size
+ Last read
+ Progress
+ Author
- Размер сетки
- Авто
- в ряд
+ Grid size
+ Auto
+ per row
- По началу
- По ширине
- По центру
- По концу
+ Start
+ Justify
+ Center
+ End
- Свободная
- Портретная
- Альбомная
- Заблокированная портретная
- Заблокированная альбомная
+ Free
+ Portrait
+ Landscape
+ Locked portrait
+ Locked landscape
- Выключено
- Включено
- Включено (инверсия)
+ Off
+ On
+ On (Inverse)
- уровень
+ level
- Выключено
- Оттенки серого
- Цвет шрифта
- Цвет фона
+ Off
+ Grayscale
+ Font color
+ Background color
- Тонкий
- Очень светлый
- Светлый
- Обычный
- Средний
+ Thin
+ Extra light
+ Light
+ Normal
+ Medium
- Проценты
- Количество
+ Percentage
+ Quantity
- Меркурий
- Нептун
- Земля
- Ио
- Энцелад
- Марс
- Каллисто
- Юпитер
- Церера
- Сатурн
- Ганимед
- Эрида
- Плутон
- Венера
- Макемаке
- Уран
- Япет
+ Mercury
+ Neptune
+ Earth
+ Io
+ Enceladus
+ Mars
+ Callisto
+ Jupiter
+ Ceres
+ Saturn
+ Ganymede
+ Eris
+ Pluto
+ Venus
+ Makemake
+ Uranus
+ Iapetus
- Нажмите «Назад» ещё раз для выхода
- Все выбранные книги добавлены.
- Все выбранные книги перемещены.
- Все выбранные книги удалены.
- Обложка изменена.
- Обложка удалена.
- Книга удалена.
- Книга перемещена.
- Скопировано
- История удалена.
- Обложка сброшена.
- Название изменено.
- Автор изменён.
- Описание изменено.
- Путь к файлу изменён.
+ Press back again to exit
+ All selected books were successfully added.
+ All selected books were successfully moved.
+ All selected books were successfully deleted.
+ Cover Image was successfully changed.
+ Cover Image was successfully deleted.
+ This book was successfully deleted.
+ This book was successfully moved.
+ Copied
+ History was successfully deleted.
+ Cover image was successfully reset.
+ Title was successfully changed.
+ Author was successfully changed.
+ Description was successfully changed.
+ File path was successfully changed.
- Элемент истории удалён.
+ History element was successfully deleted.
- Начать
- Продолжить с %1$s
- Детали файла
- Путь
- Последнее открытие
- Размер
- Неизвестно
+ Start
+ Continue from %1$s
+ File details
+ Path
+ Last opened
+ Size
+ Unknown
- Нет глав
- Главы
+ No chapters
+ Chapters
- Версия приложения
- Сообщить об ошибке
- Открытые лицензии
- Благодарности
- Помочь с переводом
- Поддержать разработку
- Участники
- Слава Україні!
+ App version
+ Report a bug
+ Open source licenses
+ Credits
+ Help translate
+ Support development
+ Contributors
+ Slava Ukraini!
Reddit
GitHub
GitLab
Codeberg
- Тризуб
+ Tryzub
- Дизайн
- Иконки
- Управление обновлениями
- Шрифты
- Перевод
- Вклад
- Идеи
+ Design
+ Icons
+ New updates managing
+ Fonts
+ Translation
+ Contribution
+ Ideas
- Добро пожаловать в %1$s!
+ Welcome to %1$s!
- В мир, где слова чаруют,
- учат и ведут в невероятные приключения!
- Давайте всё настроим!
+ To a world where words charm,
+ teach and lead on incredible adventures!
+ Let\'s set some things up!
- Языковые настройки
- Настройки темы
- Настройки сканирования
- Всё готово!
- Настройка завершена.
+ Language preferences
+ Theme preferences
+ Scan preferences
+ Everything is done!
+ Setup completed.
- Сегодня
- Вчера
+ Today
+ Yesterday
- Ой! Приложение завершилось с ошибкой.
- Пожалуйста, сообщите об ошибке удобным способом.
+ Oops! The app crashed.
+ Please report this crash message in the most convenient way for you.
- Продолжить чтение
- Назад
- Обложка
- Настройки читалки
- Файл
- Порядок сортировки
- Чекбокс
- Обложка не найдена
- Ошибка
- Поиск
- Очистить выбранные элементы
- Добавить файлы
- Выбрать все файлы
- Выбрать все книги
- Выйти из поиска
- Удалить всю историю
- Переместить выбранные книги
- Удалить выбранные книги
- Отменить
- Выбрано
- Иконка приложения
- Удалить элемент истории
- Ваша библиотека
- История чтения
- Добавить книги
- Книги
- Удалённые книги
- Аудиокниги
- Удалённые аудиокниги
- Поиск
- Поиск в сети
-
- Книжная полка
- Удалённая книжная полка
- Только оффлайн
- Ожидание
- Загрузка
- В кэше
- Ошибка
- Скачать
- Удалить
- Слушать
- Главы
- Не удалось синхронизировать прогресс чтения
- Не удалось синхронизировать прогресс воспроизведения
- Стрелка
- Сбросить начальную настройку
- Открыть в браузере
- Удалить цветовой пресет
- Создать цветовой пресет
- Перемешать цвета
- Перетащить
- Фильтр
- Контрольная точка назад
- Контрольная точка вперёд
- Главы
- Ещё
- Закрепить
- Свернуть
- Развернуть
- Изменить
- Раскрыть
- Свернуть
- Удалить
- Заметка
-
-
- Статус: %1$s
- Прогресс: %1$d / %2$d глав
- Сохранено в: %1$s
- Выбрано
- Скорость: %1$.2fx
- Создать TTS-задачу
- Книга: %1$s
- %1$s • %2$s
- Не удалось создать TTS-задачу
- Ошибка загрузки TTS
-
-
- Скорочтение
- Режим скорочтения
- Играть
- Пауза
- Назад
- −10 слов
- +10 слов
- %1$d слов/мин
- %1$d / %2$d слов
- ~%1$d мин осталось
- Скорочтение
- Запустить режим RSVP
- ▲
- ▼
- Подготовка слов…
- Не удалось извлечь текст для скорочтения
- В книге нет слов
- Скорочтение
- Скорость (слов/мин)
- Размер шрифта
- Пауза между абзацами
- Доп. пауза в конце каждого абзаца
- Пауза между главами
- Длинная пауза после заголовка главы
- Замедление длинных слов
- +30% времени на слова длиннее 9 букв
- Показывать направляющие фокуса
- ▲▼ метки над и под точкой фиксации
+ Continue reading
+ Go back
+ Cover image
+ Reader settings
+ File
+ Sort order
+ Checkbox
+ Cover image not found
+ Error
+ Search
+ Clear selected items
+ Add files
+ Select all files
+ Select all books
+ Exit search
+ Delete whole history
+ Move selected books
+ Delete selected books
+ Revert
+ Selected
+ App icon
+ Delete history element
+ Your library
+ Reading history
+ Add books
+ Bookshelf
+ Remote bookshelf
+ Offline only
+ Pending
+ Downloading
+ Cached
+ Failed
+ Download
+ Delete
+ Arrow
+ Reset start guide
+ Open in web
+ Delete color preset
+ Create new color preset
+ Shuffle colors
+ Drag
+ Filter
+ Checkpoint back
+ Checkpoint forward
+ Chapters
+ More
+ Pin
+ Show less
+ Show more
+ Edit
+ Expand
+ Collapse
+ Remove
+ Note
\ No newline at end of file
diff --git a/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt b/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt
deleted file mode 100644
index b70e4a65..00000000
--- a/app/src/test/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiServiceTest.kt
+++ /dev/null
@@ -1,422 +0,0 @@
-package org.dueattendant149.bookshelf.data.remote.bookshelfapi
-
-import kotlinx.coroutines.runBlocking
-import kotlinx.serialization.json.Json
-import okhttp3.MediaType.Companion.toMediaType
-import okhttp3.OkHttpClient
-import okhttp3.mockwebserver.MockResponse
-import okhttp3.mockwebserver.MockWebServer
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
-import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
-import org.junit.After
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
-import org.junit.Assert.assertTrue
-import org.junit.Before
-import org.junit.Test
-import retrofit2.Response
-import retrofit2.Retrofit
-import retrofit2.converter.kotlinx.serialization.asConverterFactory
-import java.util.concurrent.TimeUnit
-
-class BookshelfApiServiceTest {
-
- private lateinit var server: MockWebServer
- private lateinit var service: BookshelfApiService
-
- @Before
- fun setUp() {
- server = MockWebServer()
-
- val json = Json {
- ignoreUnknownKeys = true
- explicitNulls = false
- }
-
- val okHttpClient = OkHttpClient.Builder()
- .connectTimeout(5, TimeUnit.SECONDS)
- .readTimeout(5, TimeUnit.SECONDS)
- .build()
-
- val retrofit = Retrofit.Builder()
- .baseUrl(server.url("/"))
- .client(okHttpClient)
- .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
- .build()
-
- service = retrofit.create(BookshelfApiService::class.java)
- }
-
- @After
- fun tearDown() {
- server.shutdown()
- }
-
- private fun jsonResponse(body: String) = MockResponse()
- .setHeader("Content-Type", "application/json")
- .setBody(body)
-
- @Test
- fun `all endpoints can be created without converter errors`() {
- assertNotNull(service)
- }
-
- @Test
- fun `health returns success`() = runBlocking {
- server.enqueue(jsonResponse("""{"status":"ok"}"""))
- val response: Response = service.health()
- assertTrue(response.isSuccessful)
- }
-
- @Test
- fun `getLibraries deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- [
- {"id":"lib-1","name":"Audiobooks","media_type":"book","item_count":42},
- {"id":"lib-2","name":"Ebooks","media_type":"book","item_count":15}
- ]
- """.trimIndent()))
- val response: Response> = service.getLibraries()
- assertTrue(response.isSuccessful)
- val libraries: List = response.body()!!
- assertEquals(2, libraries.size)
- assertEquals("lib-1", libraries[0].id)
- assertEquals("Audiobooks", libraries[0].name)
- assertEquals("book", libraries[0].mediaType)
- assertEquals(42, libraries[0].itemCount)
- }
-
- @Test
- fun `getLibraryItems deserializes unified list with authors type and progress`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "library": {"id":"lib-1","name":"Books","media_type":"book","item_count":2},
- "items": [
- {
- "id":"e-1",
- "title":"Ebook",
- "authors":["A. Ebooker"],
- "type":"ebook",
- "media_type":"book",
- "library_id":"lib-1",
- "cover_url":"https://example.com/e.jpg",
- "progress": {"reading":{"itemId":"e-1","libraryId":"lib-1","scrollIndex":1,"scrollOffset":2,"progress":0.5,"updatedAt":1000}}
- },
- {
- "id":"a-1",
- "title":"Audiobook",
- "authors":["B. Audio"],
- "type":"audiobook",
- "media_type":"book",
- "library_id":"lib-1",
- "cover_url":"https://example.com/a.jpg",
- "duration":123.0,
- "progress": {"playback":{"itemId":"a-1","libraryId":"lib-1","current_file":"t.m4b","current_position":10,"duration":100,"updatedAt":2000}}
- }
- ]
- }
- """.trimIndent()))
- val response: Response = service.getLibraryItems("lib-1")
- assertTrue(response.isSuccessful)
- val items: List = response.body()!!.items
- assertEquals(2, items.size)
- assertEquals("ebook", items[0].type)
- assertEquals("audiobook", items[1].type)
- assertEquals(listOf("A. Ebooker"), items[0].authors)
- assertEquals(0.5f, items[0].progress?.reading?.progress)
- assertEquals(10L, items[1].progress?.playback?.currentPosition)
- }
-
- @Test
- fun `searchLibrary deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- [
- {
- "id":"item-1",
- "title":"War and Peace",
- "author":"Leo Tolstoy",
- "media_type":"book",
- "library_id":"lib-1",
- "cover_url":"https://example.com/cover.jpg",
- "duration":0.0,
- "size":2048000
- }
- ]
- """.trimIndent()))
- val response: Response> = service.searchLibrary("lib-1", "war")
- assertTrue(response.isSuccessful)
- val books: List = response.body()!!
- assertEquals(1, books.size)
- assertEquals("item-1", books[0].id)
- assertEquals("War and Peace", books[0].title)
- assertEquals("Leo Tolstoy", books[0].author)
- assertEquals("book", books[0].mediaType)
- assertEquals("lib-1", books[0].libraryId)
- assertEquals("https://example.com/cover.jpg", books[0].coverUrl)
- assertEquals(2048000L, books[0].size)
- }
-
- @Test
- fun `getBook returns raw response body`() = runBlocking {
- server.enqueue(jsonResponse("""{"id":"item-1","media":{"metadata":{"title":"Test"}}}"""))
- val response: Response = service.getBook("item-1")
- assertTrue(response.isSuccessful)
- val body = response.body()
- assertNotNull(body)
- val raw = body!!.string()
- assertTrue(raw.contains("item-1"))
- }
-
- @Test
- fun `updateReadingProgress succeeds`() = runBlocking {
- server.enqueue(jsonResponse("""{"itemId":"item-1","libraryId":"lib-1","scrollIndex":5,"scrollOffset":100,"progress":0.5,"last_chapter":"Ch1","updatedAt":1718611200000}"""))
- val request = ProgressUpdateRequest(
- itemId = "item-1",
- libraryId = "lib-1",
- scrollIndex = 5,
- scrollOffset = 100,
- progress = 0.5f,
- lastChapter = "Ch1",
- updatedAt = 1718611200000L,
- )
- val response: Response = service.updateReadingProgress("item-1", request)
- assertTrue(response.isSuccessful)
- }
-
- @Test
- fun `updatePlaybackProgress succeeds`() = runBlocking {
- server.enqueue(jsonResponse("""{"itemId":"item-2","libraryId":"lib-2","current_file":"track-1.m4b","current_position":12345,"duration":360000,"updatedAt":1718611200001}"""))
- val request = PlaybackProgressUpdateRequest(
- itemId = "item-2",
- libraryId = "lib-2",
- currentFile = "track-1.m4b",
- currentPosition = 12345L,
- duration = 360000L,
- updatedAt = 1718611200001L,
- )
- val response: Response = service.updatePlaybackProgress("item-2", request)
- assertTrue(response.isSuccessful)
- }
-
- @Test
- fun `getTtsEngines deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "engines": [
- {
- "id":"silero",
- "name":"Silero",
- "capabilities": {
- "supports_streaming":false,
- "supports_cloning":false,
- "max_text_length":5000,
- "needs_network":false
- }
- }
- ]
- }
- """.trimIndent()))
- val response: Response = service.getTtsEngines()
- assertTrue(response.isSuccessful)
- val engines: TtsEnginesResponse = response.body()!!
- assertEquals(1, engines.engines.size)
- assertEquals("silero", engines.engines[0].id)
- }
-
- @Test
- fun `getTtsVoices deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "voices": [
- {
- "id":"baya",
- "name":"Baya",
- "language":"ru-RU",
- "engine":"silero",
- "gender":"female",
- "quality":"high",
- "requires_reference":false
- }
- ]
- }
- """.trimIndent()))
- val response: Response = service.getTtsVoices("silero")
- assertTrue(response.isSuccessful)
- val voices: TtsVoicesResponse = response.body()!!
- assertEquals(1, voices.voices.size)
- assertEquals("baya", voices.voices[0].id)
- assertEquals("ru-RU", voices.voices[0].language)
- }
-
- @Test
- fun `createTtsJob deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "job_id":"job-1",
- "book_id":"item-1",
- "title":"",
- "author":"",
- "engine":"silero",
- "voice_id":"baya",
- "speed":1.0,
- "status":"queued",
- "progress":0.0,
- "current_chapter":"",
- "total_chapters":0,
- "completed_chapters":0,
- "output_path":"",
- "error":"",
- "created_at":1718611200.0,
- "started_at":0.0,
- "completed_at":0.0
- }
- """.trimIndent()))
- val request = TtsCreateRequest(bookId = "item-1", engine = "silero", voiceId = "baya", speed = 1.0)
- val response: Response = service.createTtsJob(request)
- assertTrue(response.isSuccessful)
- val job: TtsJobResponse = response.body()!!
- assertEquals("job-1", job.jobId)
- assertEquals("queued", job.status)
- }
-
- @Test
- fun `listTtsJobs deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "jobs": [
- {
- "job_id":"job-1",
- "book_id":"item-1",
- "title":"Test Book",
- "author":"Author",
- "engine":"silero",
- "voice_id":"baya",
- "speed":1.0,
- "status":"completed",
- "progress":1.0,
- "current_chapter":"Chapter 10",
- "total_chapters":10,
- "completed_chapters":10,
- "output_path":"/audiobooks/Test.m4b",
- "error":"",
- "created_at":1718611200.0,
- "started_at":1718611300.0,
- "completed_at":1718611900.0
- }
- ]
- }
- """.trimIndent()))
- val response: Response = service.listTtsJobs()
- assertTrue(response.isSuccessful)
- val jobs: TtsJobsResponse = response.body()!!
- assertEquals(1, jobs.jobs.size)
- assertEquals("completed", jobs.jobs[0].status)
- }
-
- @Test
- fun `search deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "results": [
- {
- "title":"Test Book",
- "author":"Author",
- "size":"10 MB",
- "size_human":"10 MB",
- "seeders":5,
- "media_type":"book",
- "magnet_url":"magnet:?xt=urn:btih:abc",
- "download_url":"https://example.com/dl",
- "cover_url":"https://example.com/cover.jpg",
- "info_hash":"abc123",
- "source":"flibusta",
- "download_protocol":"torrent",
- "language":"ru"
- }
- ],
- "total":1,
- "search_time_ms":150
- }
- """.trimIndent()))
- val request = SearchRequest(query = "test")
- val response: Response = service.search(request)
- assertTrue(response.isSuccessful)
- val search: SearchResponse = response.body()!!
- assertEquals(1, search.total)
- assertEquals("Test Book", search.results[0].title)
- }
-
- @Test
- fun `startDownload deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""{"success":true,"error":null,"download_id":"dl-1"}"""))
- val request = DownloadRequest(url = "magnet:?xt=urn:btih:abc", title = "Test")
- val response: Response = service.startDownload(request)
- assertTrue(response.isSuccessful)
- val download: DownloadResponse = response.body()!!
- assertTrue(download.success)
- assertEquals("dl-1", download.downloadId)
- }
-
- @Test
- fun `listDownloads deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""
- {
- "downloads": [
- {
- "id":"dl-1",
- "title":"Test Book",
- "status":"downloading",
- "progress":0.5,
- "error":null,
- "created_at":1718611200.0
- }
- ]
- }
- """.trimIndent()))
- val response: Response = service.listDownloads()
- assertTrue(response.isSuccessful)
- val downloads: DownloadsListResponse = response.body()!!
- assertEquals(1, downloads.downloads.size)
- assertEquals("downloading", downloads.downloads[0].status)
- }
-
- @Test
- fun `downloadPodcast deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""{"url":"https://youtube.com/watch?v=abc","title":"Podcast","tracks":3,"files":["f1.m4a","f2.m4a"],"abs_scan_triggered":true}"""))
- val request = PodcastRequest(url = "https://youtube.com/watch?v=abc")
- val response: Response = service.downloadPodcast(request)
- assertTrue(response.isSuccessful)
- val podcast: PodcastResponse = response.body()!!
- assertEquals("Podcast", podcast.title)
- assertTrue(podcast.absScanTriggered)
- }
-
- @Test
- fun `downloadYandex deserializes correctly`() = runBlocking {
- server.enqueue(jsonResponse("""{"bookid":"123","files":["book.epub"],"abs_scan_triggered":false}"""))
- val request = YandexRequest(bookid = "123")
- val response: Response = service.downloadYandex(request)
- assertTrue(response.isSuccessful)
- val yandex: YandexResponse = response.body()!!
- assertEquals("123", yandex.bookid)
- }
-}
diff --git a/build.gradle.kts b/build.gradle.kts
index bf2e129f..f076ace1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,7 +2,6 @@ plugins {
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.2.0" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "2.2.0" apply false
id("com.google.devtools.ksp") version "2.2.0-2.0.2" apply false
id("com.google.dagger.hilt.android") version "2.57.2" apply false
id("com.mikepenz.aboutlibraries.plugin") version "11.1.3" apply false