Compare commits

..

No commits in common. "f7307f41999d91a7dbfe3c5b02e269fe2bea36b5" and "c8c1503d8c37e99c7d43c2307897dba56517266e" have entirely different histories.

136 changed files with 1248 additions and 9628 deletions

View file

@ -13,7 +13,7 @@
- **Аудио:** ExoPlayer (Media3) + MediaSession - **Аудио:** ExoPlayer (Media3) + MediaSession
- **Фоновые задачи:** WorkManager - **Фоновые задачи:** WorkManager
- **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark) - **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark)
- **minSdk:** 31 - **minSdk:** 26 (план — поднять до 31 позже)
- **compileSdk/targetSdk:** 36 - **compileSdk/targetSdk:** 36
## Goal ## Goal
@ -66,46 +66,14 @@
- Phase 1: fork + package rename completed. - Phase 1: fork + package rename completed.
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) 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 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) 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 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) completed. - Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged.
- Phase 6: feature integration on `feature/ui-polish` completed: - Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model.
- RemoteLibrary cache UI (offline-only filter, download/delete, progress indicator). - Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`.
- Remote ebook opening in the existing reader. - Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync.
- 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` (100900), `rsvpFontSize` (2496 sp), `rsvpPauseOnParagraph`, `rsvpPauseOnChapter`, `rsvpPauseOnLongWords`, `rsvpShowFocusGuide`.
- Phase 9 (reader pagination + polish):
- Kindle-style paginated reading mode with `HorizontalPager`.
- Volume keys (Up/Down) mapped to page navigation via `VolumeKeyWindowCallback`.
- Pagination measurement/render synchronization: exact page geometry, real inter-item spacing, image sizing, chapter title extras.
- Algorithm improvements: `TextLayoutResult.getLineForVerticalPosition()` split point, better widow/orphan handling.
- Phase 10 (bugfixes):
- Fixed search crash caused by duplicate LazyColumn keys when search results have empty `guid`.
- Fixed `BookCard` intrinsic-measurement crash risk by switching cover to fixed-height vertical variant.
- Phase 11 (audio player hardening):
- ExoPlayer streaming cache via `SimpleCache` + `CacheDataSource` (512 MB external cache).
- Unified `AuthorizationInterceptor` for player, backed by `PlaybackAuthProvider` refreshed from `ServerSettings`.
- Current APK: `KRait-debug-27.apk` (1.9.11) on `books.dueattendant149.org/download`.
- Next: final APK rebuild after pushing to Forgejo.
## WARNs ## WARNs

View file

@ -2,7 +2,6 @@ plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
id("com.google.devtools.ksp") id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
id("kotlin-parcelize") id("kotlin-parcelize")
@ -17,10 +16,10 @@ android {
// Default configuration // Default configuration
defaultConfig { defaultConfig {
applicationId = "org.dueattendant149.bookshelf" applicationId = "org.dueattendant149.bookshelf"
minSdk = 31 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 27 versionCode = 14
versionName = "1.9.11" versionName = "1.8.0"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@ -180,14 +179,4 @@ dependencies {
// Background work (cache, TTS sync) // Background work (cache, TTS sync)
implementation("androidx.work:work-runtime-ktx:2.10.0") 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")
} }

View file

@ -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')"
]
}
}

View file

@ -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')"
]
}
}

View file

@ -22,12 +22,6 @@
tools:node="remove" tools:node="remove"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<!-- Audio playback -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".Application" android:name=".Application"
android:allowBackup="false" android:allowBackup="false"
@ -62,16 +56,6 @@
android:theme="@style/BookStory" android:theme="@style/BookStory"
android:windowSoftInputMode="adjustResize" /> android:windowSoftInputMode="adjustResize" />
<!-- Audiobook playback service -->
<service
android:name=".data.playback.AudioPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
</intent-filter>
</service>
<!-- Save app locales --> <!-- Save app locales -->
<service <service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService" android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

View file

@ -7,36 +7,13 @@
package org.dueattendant149.bookshelf package org.dueattendant149.bookshelf
import android.app.Application import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import androidx.work.WorkManager
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookshelf.core.crash.CrashHandler import org.dueattendant149.bookshelf.core.crash.CrashHandler
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class Application : Application(), Configuration.Provider { class Application : Application() {
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var workManager: WorkManager
@Inject
lateinit var serverStatusMonitor: ServerStatusMonitor
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this)) Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
ProgressSyncWorker.schedule(workManager)
serverStatusMonitor.start()
} }
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
} }

View file

@ -10,9 +10,8 @@ import kotlinx.collections.immutable.toPersistentList
import org.dueattendant149.bookshelf.core.language.Language import org.dueattendant149.bookshelf.core.language.Language
object CoreData { object CoreData {
val defaultLanguage = Language.fromLanguageTag(languageTag = "ru") // Russian val defaultLanguage = Language.fromLanguageTag(languageTag = "en") // English
val languages = listOf( val languages = listOf(
Language.fromLanguageTag(languageTag = "ru"), // Russian
Language.fromLanguageTag(languageTag = "en"), // English Language.fromLanguageTag(languageTag = "en"), // English
Language.fromLanguageTag(languageTag = "uk"), // Ukrainian Language.fromLanguageTag(languageTag = "uk"), // Ukrainian
Language.fromLanguageTag(languageTag = "de"), // German Language.fromLanguageTag(languageTag = "de"), // German

View file

@ -66,8 +66,6 @@ object AppModule {
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity
DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables
DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields
DatabaseHelper.MANUAL_MIGRATION_18_19, // add itemType column
).allowMainThreadQueries().build().also { database -> ).allowMainThreadQueries().build().also { database ->
// Additional Migrations // Additional Migrations
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app) DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)

View file

@ -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.file.FileParserImpl
import org.dueattendant149.bookshelf.data.parser.text.TextParser import org.dueattendant149.bookshelf.data.parser.text.TextParser
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl 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.BookRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl 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.FileSystemRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl 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.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.BookRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository 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.FileSystemRepository
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository 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.RemoteLibraryRepository
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -109,24 +103,6 @@ abstract class RepositoryModule {
cacheRepositoryImpl: CacheRepositoryImpl cacheRepositoryImpl: CacheRepositoryImpl
): CacheRepository ): 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 @Binds
@Singleton @Singleton
abstract fun bindBookMapper( abstract fun bindBookMapper(

View file

@ -32,8 +32,6 @@ data class BookEntity(
val libraryId: String = "", val libraryId: String = "",
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
val mediaType: String = "", val mediaType: String = "",
@ColumnInfo(defaultValue = "")
val itemType: String = "",
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val hasAudio: Boolean = false, val hasAudio: Boolean = false,
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
@ -41,10 +39,6 @@ data class BookEntity(
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val audioDuration: Long = 0L, val audioDuration: Long = 0L,
@ColumnInfo(defaultValue = "") @ColumnInfo(defaultValue = "")
val audioCurrentFile: String = "",
@ColumnInfo(defaultValue = "0")
val audioCurrentPosition: Long = 0L,
@ColumnInfo(defaultValue = "")
val coverUrl: String = "", val coverUrl: String = "",
@ColumnInfo(defaultValue = "0") @ColumnInfo(defaultValue = "0")
val lastSyncedAt: Long = 0L, val lastSyncedAt: Long = 0L,

View file

@ -38,9 +38,6 @@ interface BookDao {
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId") @Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity> suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
@Query("SELECT * FROM bookentity WHERE remoteId != ''")
suspend fun findBooksWithRemoteId(): List<BookEntity>
@Delete @Delete
suspend fun deleteBook(book: BookEntity): Int suspend fun deleteBook(book: BookEntity): Int

View file

@ -34,7 +34,7 @@ import java.io.File
EbookFileEntity::class, EbookFileEntity::class,
CachedFileEntity::class, CachedFileEntity::class,
], ],
version = 19, version = 17,
autoMigrations = [ autoMigrations = [
AutoMigration(1, 2), AutoMigration(1, 2),
AutoMigration(2, 3), AutoMigration(2, 3),
@ -203,25 +203,6 @@ object DatabaseHelper {
@DeleteTable("CategorySortEntity") @DeleteTable("CategorySortEntity")
class AUTO_MIGRATION_15_16 : AutoMigrationSpec 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) { val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(database: SupportSQLiteDatabase) { override fun migrate(database: SupportSQLiteDatabase) {
// Add remote / bookshelf columns to BookEntity // Add remote / bookshelf columns to BookEntity

View file

@ -29,12 +29,9 @@ class BookMapperImpl @Inject constructor() : BookMapper {
remoteId = book.remoteId, remoteId = book.remoteId,
libraryId = book.libraryId, libraryId = book.libraryId,
mediaType = book.mediaType, mediaType = book.mediaType,
itemType = book.itemType,
hasAudio = book.hasAudio, hasAudio = book.hasAudio,
hasEbook = book.hasEbook, hasEbook = book.hasEbook,
audioDuration = book.audioDuration, audioDuration = book.audioDuration,
audioCurrentFile = book.audioCurrentFile,
audioCurrentPosition = book.audioCurrentPosition,
coverUrl = book.coverUrl, coverUrl = book.coverUrl,
lastSyncedAt = book.lastSyncedAt, lastSyncedAt = book.lastSyncedAt,
) )
@ -59,12 +56,9 @@ class BookMapperImpl @Inject constructor() : BookMapper {
remoteId = bookEntity.remoteId, remoteId = bookEntity.remoteId,
libraryId = bookEntity.libraryId, libraryId = bookEntity.libraryId,
mediaType = bookEntity.mediaType, mediaType = bookEntity.mediaType,
itemType = bookEntity.itemType,
hasAudio = bookEntity.hasAudio, hasAudio = bookEntity.hasAudio,
hasEbook = bookEntity.hasEbook, hasEbook = bookEntity.hasEbook,
audioDuration = bookEntity.audioDuration, audioDuration = bookEntity.audioDuration,
audioCurrentFile = bookEntity.audioCurrentFile,
audioCurrentPosition = bookEntity.audioCurrentPosition,
coverUrl = bookEntity.coverUrl, coverUrl = bookEntity.coverUrl,
lastSyncedAt = bookEntity.lastSyncedAt, lastSyncedAt = bookEntity.lastSyncedAt,
) )

View file

@ -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
}
}

View file

@ -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),
)
}
}

View file

@ -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()
}
}

View file

@ -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()
}
}

View file

@ -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 <token>` 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)
}
}
}

View file

@ -1,12 +1,8 @@
package org.dueattendant149.bookshelf.data.remote.audiobookshelf package org.dueattendant149.bookshelf.data.remote.audiobookshelf
import okhttp3.ResponseBody 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.Response
import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Streaming import retrofit2.http.Streaming
@ -38,15 +34,4 @@ interface AudiobookshelfApiService {
suspend fun getProgress( suspend fun getProgress(
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
): Response<String> ): Response<String>
@GET("api/items/{itemId}")
suspend fun getItem(
@Path("itemId") itemId: String,
): Response<AbsItemResponse>
@POST("api/me/progress/{itemId}")
suspend fun updateProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<String>
} }

View file

@ -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,
)

View file

@ -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<AbsAudioFile> = 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,
)

View file

@ -1,17 +1,13 @@
package org.dueattendant149.bookshelf.data.remote.bookshelfapi package org.dueattendant149.bookshelf.data.remote.bookshelfapi
import okhttp3.ResponseBody 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.BookItemResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest 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.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse 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.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.PodcastRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse 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.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse 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.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.TtsJobResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse 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.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.YandexRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Multipart import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path import retrofit2.http.Path
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.Streaming import retrofit2.http.Streaming
import retrofit2.http.POST
/** /**
* Retrofit description of the Bookshelf API (bookshelf-api:8073). * Retrofit description of the Bookshelf API (bookshelf-api:8073).
@ -42,54 +33,16 @@ interface BookshelfApiService {
// Health // Health
@GET("health") @GET("health")
suspend fun health(): Response<Unit> suspend fun health(): Response<String>
// Libraries / books // Libraries / books
@GET("api/v1/books/libraries") @GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>> suspend fun getLibraries(): Response<List<LibraryResponse>>
@Streaming
@GET("api/v1/books/{itemId}/ebook")
suspend fun downloadEbook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/v1/books/{itemId}/tracks")
suspend fun getAudioTracks(
@Path("itemId") itemId: String,
): Response<List<AudioTrackResponse>>
@Streaming
@GET("api/v1/books/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/v1/books/{itemId}") @GET("api/v1/books/{itemId}")
suspend fun getBook( suspend fun getBook(
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
): Response<ResponseBody> ): Response<String>
@Streaming
@POST("api/v1/books/{itemId}/progress")
suspend fun updateReadingProgress(
@Path("itemId") itemId: String,
@Body request: ProgressUpdateRequest,
): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/playback-progress")
suspend fun updatePlaybackProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<ResponseBody>
@GET("api/v1/books/library/{libraryId}/items")
suspend fun getLibraryItems(
@Path("libraryId") libraryId: String,
): Response<LibraryItemsResponse>
@GET("api/v1/books/library/{libraryId}/search") @GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary( suspend fun searchLibrary(
@ -130,13 +83,6 @@ interface BookshelfApiService {
@Path("jobId") jobId: String, @Path("jobId") jobId: String,
): Response<ResponseBody> ): Response<ResponseBody>
@Multipart
@POST("api/v1/upload/book")
suspend fun uploadBook(
@Part file: MultipartBody.Part,
@Part("book_type") bookType: RequestBody,
): Response<UploadBookResponse>
// Downloads / sources // Downloads / sources
@POST("api/v1/download") @POST("api/v1/download")
suspend fun startDownload( suspend fun startDownload(

View file

@ -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)
}
}

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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<UnifiedItemResponse> = emptyList(),
)
@Serializable
data class UnifiedItemResponse(
val id: String,
val title: String = "",
val authors: List<String> = 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,
)

View file

@ -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,
)

View file

@ -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<List<AudioTrack>> {
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)
}
}
}

View file

@ -92,10 +92,4 @@ class BookRepositoryImpl @Inject constructor(
} }
} }
} }
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
withContext(Dispatchers.IO) {
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
}
}
} }

View file

@ -7,26 +7,22 @@
package org.dueattendant149.bookshelf.data.repository package org.dueattendant149.bookshelf.data.repository
import android.app.Application import android.app.Application
import android.util.Log import android.webkit.URLUtil
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper 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.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheState import org.dueattendant149.bookshelf.domain.model.cache.CacheState
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import retrofit2.Response
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -38,275 +34,156 @@ class CacheRepositoryImpl
private val application: Application, private val application: Application,
private val database: BookDatabase, private val database: BookDatabase,
private val bookMapper: BookMapper, private val bookMapper: BookMapper,
private val apiFactory: BookshelfApiClientFactory, private val apiFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings, private val serverSettings: ServerSettings,
private val audiobookshelfRepository: AudiobookshelfRepository,
) : CacheRepository { ) : CacheRepository {
private val cachedFileDao by lazy { database.cachedFileDao } private val cachedFileDao by lazy { database.cachedFileDao }
private val audioFileDao by lazy { database.audioFileDao }
override suspend fun cacheBook(book: Book): Result<Int> = runCatching { override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
require(book.remoteId.isNotBlank()) { "Book has no remote id." } require(book.hasEbook) { "Book has no ebook." }
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
val existing = database.bookDao.findBookByRemoteId(book.remoteId) val existing = database.bookDao.findBookByRemoteId(book.remoteId)
val localBookId = existing?.id ?: insertLocalBook(book) val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
if (book.hasEbook) { if (existing != null &&
cacheEbook(book, localBookId) cachedEbook?.localPath != null &&
} File(cachedEbook.localPath).exists()
) {
if (book.hasAudio) { if (existing.filePath != cachedEbook.localPath) {
cacheAudiobook(book, localBookId) database.bookDao.updateBook(
} bookMapper.toBookEntity(
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
localBookId )
} )
}.onFailure { }
Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it) return@withContext existing.id
}
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,
)
} }
)
}
tracks.forEachIndexed { index, track -> val url = serverSettings.getAbsUrl()
val cached = audioFileDao.getByBookId(bookId) val token = serverSettings.getAbsToken()
.firstOrNull { it.fileId == track.fileId } if (url.isNullOrBlank() || token.isNullOrBlank()) {
if (cached?.localPath != null && File(cached.localPath).exists()) { throw IllegalStateException("Audiobookshelf server is not configured.")
updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath) }
return@forEachIndexed
}
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading") val client = apiFactory.provideClient(url, token)
val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}" ?: throw IllegalStateException("Could not create Audiobookshelf client.")
val response = client.downloadAudioFile(book.remoteId, track.fileId)
if (!response.isSuccessful) {
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
throw IllegalStateException("Audio track $index download failed: ${response.code()}")
}
val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
audioFileDao.update( val response = client.downloadBook(book.remoteId)
(cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId)) if (!response.isSuccessful) {
.copy(localPath = file.absolutePath) throw IllegalStateException("Download failed: ${response.code()}")
) }
val overallProgress = (index + 1).toFloat() / tracks.size val body = response.body()
updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed") ?: throw IllegalStateException("Download response body is empty.")
cachedFileDao.insert(
CachedFileEntity( val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
bookId = bookId, val fileName = guessFileName(response, book.remoteId)
type = "audio_${track.fileId}", val file = File(ebooksDir, fileName)
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
localPath = file.absolutePath, body.byteStream().use { input ->
status = "completed", file.outputStream().use { output ->
progress = overallProgress, 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( cachedFileDao.insert(
bookId: Int, CachedFileEntity(
fileId: String, bookId = inserted.id,
progress: Float, type = "ebook",
localPath: String?, remoteUrl = "$url/api/items/${book.remoteId}/download",
status: String = "completed", localPath = file.absolutePath,
) { status = "completed",
val type = "audio_$fileId" progress = 1f,
val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull() )
val entity = CachedFileEntity(
id = existing?.id ?: 0,
bookId = bookId,
type = type,
remoteUrl = existing?.remoteUrl ?: "",
localPath = localPath ?: existing?.localPath,
status = status,
progress = progress,
)
if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
}
private suspend fun updateBookFilePath(book: Book, path: String) {
val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
if (existing.filePath != path) {
database.bookDao.updateBook(
bookMapper.toBookEntity(
bookMapper.toBook(existing).copy(filePath = path)
) )
)
}
}
private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File { inserted.id
val dir = File(application.filesDir, dirName).apply { mkdirs() } }
val file = File(dir, fileName)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} }
return file
}
private fun guessAudioExtension(fileNameOrTitle: String): String { override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
val name = fileNameOrTitle.lowercase() withContext(Dispatchers.IO) {
return when { val book = database.bookDao.findBookByRemoteId(remoteId)
name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b" ?: throw IllegalStateException("Book not found")
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<Unit> = runCatching {
withContext(Dispatchers.IO) {
val book = database.bookDao.findBookByRemoteId(remoteId)
if (book != null) {
val ebook = database.ebookFileDao.getByBookId(book.id) val ebook = database.ebookFileDao.getByBookId(book.id)
val cachedFiles = cachedFileDao.getByBookId(book.id) val cachedFiles = cachedFileDao.getByBookId(book.id)
val audioFiles = audioFileDao.getByBookId(book.id)
ebook?.localPath?.let { File(it).delete() } ebook?.localPath?.let { File(it).delete() }
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() } cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
database.ebookFileDao.deleteByBookId(book.id) database.ebookFileDao.deleteByBookId(book.id)
cachedFileDao.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<CacheStatus> = flow { override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
val book = database.bookDao.findBookByRemoteId(remoteId) val book = database.bookDao.findBookByRemoteId(remoteId)
if (book == null) { if (book == null) {
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList())) emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
return@flow 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 -> private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
val files = entities.map { val contentDisposition = response.headers()["Content-Disposition"]
CachedFile( val mimeType = response.body()?.contentType()?.toString()
type = it.type, val url = response.raw().request.url.toString()
status = it.status, val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
progress = it.progress, return if (name.contains(".")) name else "$remoteId.epub"
localPath = it.localPath
)
}
val state = when {
entities.isEmpty() -> CacheState.NONE
entities.all { it.status == "completed" } -> CacheState.COMPLETED
entities.any { it.status == "failed" } -> CacheState.FAILED
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
entities.any { it.status == "pending" } -> CacheState.PENDING
else -> CacheState.NONE
}
val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat()
emit(CacheStatus(remoteId, state, progress, files))
} }
} }
private fun guessFileName(response: Response<ResponseBody>, 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<AudioFileEntity> =
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() } }
}
}

View file

@ -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<Unit> = 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<Unit> = 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<List<Book>> = 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"
}
}

View file

@ -7,21 +7,9 @@
package org.dueattendant149.bookshelf.data.repository package org.dueattendant149.bookshelf.data.repository
import android.util.Log import android.util.Log
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.dueattendant149.bookshelf.core.ui.UIText 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.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.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.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
@ -35,8 +23,6 @@ class RemoteLibraryRepositoryImpl
constructor( constructor(
private val clientFactory: BookshelfApiClientFactory, private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings, private val serverSettings: ServerSettings,
private val database: BookDatabase,
private val bookMapper: BookMapper,
) : RemoteLibraryRepository { ) : RemoteLibraryRepository {
override suspend fun getLibraries(): Result<List<RemoteLibrary>> { override suspend fun getLibraries(): Result<List<RemoteLibrary>> {
return runCatching { return runCatching {
@ -52,42 +38,7 @@ class RemoteLibraryRepositoryImpl
} }
override suspend fun getBooks(libraryId: String): Result<List<Book>> { override suspend fun getBooks(libraryId: String): Result<List<Book>> {
return runCatching { return searchLibrary(libraryId, "")
val client = client() ?: return Result.success(emptyList())
val response = client.getLibraryItems(libraryId)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to load library items: ${response.code()}"))
}
val body = response.body() ?: LibraryItemsResponse()
val items = body.items.map { it.toBook() }
// Upsert each item into the local DB keyed by remoteId,
// preserving local file path/progress if the book already exists.
items.forEach { book ->
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
val toStore = if (existing != null) {
val existingBook = bookMapper.toBook(existing)
book.copy(
id = existingBook.id,
filePath = existingBook.filePath,
scrollIndex = existingBook.scrollIndex,
scrollOffset = existingBook.scrollOffset,
progress = existingBook.progress,
categories = existingBook.categories,
lastOpened = existingBook.lastOpened,
)
} else {
book
}
database.bookDao.insertBook(bookMapper.toBookEntity(toStore))
}
Result.success(items)
}.getOrElse {
Log.e("RemoteLibraryRepo", "getBooks failed", it)
Result.failure(it)
}
} }
override suspend fun searchLibrary( override suspend fun searchLibrary(
@ -106,59 +57,6 @@ class RemoteLibraryRepositoryImpl
} }
} }
override suspend fun search(request: SearchRequest): Result<SearchResponse> {
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<DownloadResponse> {
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<DownloadsListResponse> {
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<UploadBookResponse> = 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 suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
private fun LibraryResponse.toDomain() = private fun LibraryResponse.toDomain() =
@ -169,54 +67,7 @@ class RemoteLibraryRepositoryImpl
itemCount = itemCount, itemCount = itemCount,
) )
private suspend fun UnifiedItemResponse.toBook(): Book { private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
val resolvedCoverUrl = resolveCoverUrl(coverUrl)
return Book(
id = 0,
title = title,
author = UIText.StringValue(
author.takeIf { it.isNotBlank() }
?: authors.filter { it.isNotBlank() }.joinToString(", ")
.takeIf { it.isNotBlank() }
?: ""
),
description = null,
filePath = "",
coverImage = null,
scrollIndex = progress?.reading?.scrollIndex ?: 0,
scrollOffset = progress?.reading?.scrollOffset ?: 0,
progress = progress?.reading?.progress ?: 0f,
lastOpened = null,
categories = emptyList(),
remoteId = id,
libraryId = libraryId,
mediaType = mediaType,
itemType = type,
hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid",
hasEbook = type == "ebook" || type == "hybrid",
audioDuration = progress?.playback?.duration ?: duration.toLong(),
audioCurrentFile = progress?.playback?.currentFile ?: "",
audioCurrentPosition = progress?.playback?.currentPosition ?: 0L,
coverUrl = resolvedCoverUrl,
lastSyncedAt = System.currentTimeMillis(),
)
}
private suspend fun resolveCoverUrl(coverUrl: String): String {
if (coverUrl.isBlank()) return ""
val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl
val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/')
return when {
coverUrl.startsWith("http://", ignoreCase = true) ||
coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl
coverUrl.startsWith("/") -> "$base$coverUrl"
else -> "$base/$coverUrl"
}
}
private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
Book( Book(
id = 0, id = 0,
title = title, title = title,
@ -232,7 +83,6 @@ class RemoteLibraryRepositoryImpl
remoteId = id, remoteId = id,
libraryId = libraryId, libraryId = libraryId,
mediaType = mediaType, mediaType = mediaType,
itemType = mediaType,
hasAudio = mediaType == "audiobook" || mediaType == "podcast", hasAudio = mediaType == "audiobook" || mediaType == "podcast",
hasEbook = mediaType == "book", hasEbook = mediaType == "book",
audioDuration = duration.toLong(), audioDuration = duration.toLong(),

View file

@ -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<List<TtsEngine>> {
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<List<TtsVoice>> {
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<TtsJob> {
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<TtsJob> {
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<ByteArray> {
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,
)
}

View file

@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.data.settings
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import org.dueattendant149.bookshelf.data.local.data_store.DataStore 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.isValidUri
import org.dueattendant149.bookshelf.domain.util.normalizeUri import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject import javax.inject.Inject
@ -48,12 +47,11 @@ class ServerSettings
* or null when all credentials are present. * or null when all credentials are present.
*/ */
suspend fun getMissingCredentialsError(): String? { suspend fun getMissingCredentialsError(): String? {
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)?.trim() val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)
if (bookshelfUrl.isNullOrBlank()) { if (bookshelfUrl.isNullOrBlank()) {
return "Bookshelf API URL is missing" return "Bookshelf API URL is missing"
} }
val normalizedBookshelfUrl = bookshelfUrl.ensureUriScheme() if (!bookshelfUrl.isValidUri()) {
if (!normalizedBookshelfUrl.isValidUri()) {
return "Bookshelf API URL is invalid: $bookshelfUrl" return "Bookshelf API URL is invalid: $bookshelfUrl"
} }
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) { if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
@ -69,8 +67,8 @@ class ServerSettings
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim() val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
return when { return when {
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty")) url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
!url.ensureUriScheme().isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url")) !url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
else -> Result.success(url.ensureUriScheme().normalizeUri()) else -> Result.success(url.normalizeUri())
} }
} }
@ -82,8 +80,6 @@ class ServerSettings
private fun String.normalizeIfValid(): String? { private fun String.normalizeIfValid(): String? {
val trimmed = trim() val trimmed = trim()
if (trimmed.isBlank()) return null return if (trimmed.isValidUri()) trimmed.normalizeUri() else null
val withScheme = trimmed.ensureUriScheme()
return if (withScheme.isValidUri()) withScheme.normalizeUri() else null
} }
} }

View file

@ -180,26 +180,6 @@ class SettingsManager @Inject constructor(
val perceptionExpanderThickness = setting<Int, Int>( val perceptionExpanderThickness = setting<Int, Int>(
key = intPreferencesKey("perception_expander_thickness"), default = 4 key = intPreferencesKey("perception_expander_thickness"), default = 4
) )
// RSVP (speed reading)
val rsvpWpm = setting<Int, Int>(
key = intPreferencesKey("rsvp_wpm"), default = 350
)
val rsvpFontSize = setting<Int, Int>(
key = intPreferencesKey("rsvp_font_size"), default = 56
)
val rsvpPauseOnParagraph = setting<Boolean, Boolean>(
key = booleanPreferencesKey("rsvp_pause_paragraph"), default = true
)
val rsvpPauseOnChapter = setting<Boolean, Boolean>(
key = booleanPreferencesKey("rsvp_pause_chapter"), default = true
)
val rsvpPauseOnLongWords = setting<Boolean, Boolean>(
key = booleanPreferencesKey("rsvp_pause_long_words"), default = true
)
val rsvpShowFocusGuide = setting<Boolean, Boolean>(
key = booleanPreferencesKey("rsvp_show_focus_guide"), default = true
)
val horizontalLimiter = setting<Boolean, Boolean>( val horizontalLimiter = setting<Boolean, Boolean>(
key = booleanPreferencesKey("horizontal_limiter"), default = false key = booleanPreferencesKey("horizontal_limiter"), default = false
) )
@ -231,9 +211,6 @@ class SettingsManager @Inject constructor(
key = doublePreferencesKey("screen_brightness"), default = 0.5f, key = doublePreferencesKey("screen_brightness"), default = 0.5f,
serialize = { it.toDouble() }, deserialize = { it.toFloat() } serialize = { it.toDouble() }, deserialize = { it.toFloat() }
) )
val readerPagination = setting<Boolean, Boolean>(
key = booleanPreferencesKey("reader_pagination"), default = true
)
val horizontalGesture = setting<ReaderHorizontalGesture, String>( val horizontalGesture = setting<ReaderHorizontalGesture, String>(
key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF, key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF,
serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) } serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) }

View file

@ -96,14 +96,13 @@ class CacheDownloadWorker(
} }
if (hasAudio) { if (hasAudio) {
downloadAudioTracks( downloadFile(
database = database, database = database,
bookId = bookId, bookId = bookId,
absUrl = absUrl, type = "audio",
remoteId = remoteId, remoteUrl = "$absUrl/api/items/$remoteId/download",
bookDir = bookDir, localFile = File(bookDir, "audio"),
client = client, ) { client.downloadBook(remoteId) }
)
} }
Result.success() 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( private suspend fun downloadFile(
database: BookDatabase, database: BookDatabase,
bookId: Int, bookId: Int,

View file

@ -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<ProgressSyncWorker>(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"

View file

@ -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()
}
}

View file

@ -35,12 +35,9 @@ data class Book(
val remoteId: String = "", val remoteId: String = "",
val libraryId: String = "", val libraryId: String = "",
val mediaType: String = "", val mediaType: String = "",
val itemType: String = "",
val hasAudio: Boolean = false, val hasAudio: Boolean = false,
val hasEbook: Boolean = false, val hasEbook: Boolean = false,
val audioDuration: Long = 0L, val audioDuration: Long = 0L,
val audioCurrentFile: String = "",
val audioCurrentPosition: Long = 0L,
val coverUrl: String = "", val coverUrl: String = "",
val lastSyncedAt: Long = 0L, val lastSyncedAt: Long = 0L,
) : Parcelable { ) : Parcelable {
@ -60,12 +57,9 @@ data class Book(
remoteId = "", remoteId = "",
libraryId = "", libraryId = "",
mediaType = "", mediaType = "",
itemType = "",
hasAudio = false, hasAudio = false,
hasEbook = false, hasEbook = false,
audioDuration = 0L, audioDuration = 0L,
audioCurrentFile = "",
audioCurrentPosition = 0L,
coverUrl = "", coverUrl = "",
lastSyncedAt = 0L, lastSyncedAt = 0L,
) )

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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<ReaderText>,
val startTextIndex: Int,
val endTextIndex: Int,
)

View file

@ -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<RsvpToken> = 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> = _state.asStateFlow()
private var playbackJob: Job? = null
fun setTokens(tokens: List<RsvpToken>) {
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)
}
}

View file

@ -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:
* 10, 20, 31, 41, 51, 62, 72, 82, 92, 103, 113, 123, 133.
*/
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)
}
}

View file

@ -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<ReaderText>): List<RsvpToken> {
val tokens = ArrayList<RsvpToken>(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<RsvpToken>,
) {
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
}
}

View file

@ -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"
}

View file

@ -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<List<AudioTrack>>
}

View file

@ -43,6 +43,4 @@ interface BookRepository {
suspend fun getDefaultCover( suspend fun getDefaultCover(
book: Book book: Book
): Result<CoverImage?> ): Result<CoverImage?>
suspend fun getBooksWithRemoteId(): Result<List<Book>>
} }

View file

@ -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<Unit>
suspend fun syncPlaybackProgress(
book: Book,
currentFile: String,
position: Long,
duration: Long,
): Result<Unit>
suspend fun getBooksWithRemoteId(): Result<List<Book>>
}

View file

@ -6,11 +6,6 @@
package org.dueattendant149.bookshelf.domain.repository 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.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
@ -18,8 +13,4 @@ interface RemoteLibraryRepository {
suspend fun getLibraries(): Result<List<RemoteLibrary>> suspend fun getLibraries(): Result<List<RemoteLibrary>>
suspend fun getBooks(libraryId: String): Result<List<Book>> suspend fun getBooks(libraryId: String): Result<List<Book>>
suspend fun searchLibrary(libraryId: String, query: String): Result<List<Book>> suspend fun searchLibrary(libraryId: String, query: String): Result<List<Book>>
suspend fun search(request: SearchRequest): Result<SearchResponse>
suspend fun startDownload(request: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest): Result<org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse>
suspend fun listDownloads(): Result<org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse>
suspend fun uploadBook(file: MultipartBody.Part, bookType: RequestBody): Result<UploadBookResponse>
} }

View file

@ -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<List<TtsEngine>>
suspend fun fetchVoices(engine: String? = null): Result<List<TtsVoice>>
suspend fun createJob(
bookId: String,
engine: String,
voiceId: String,
speed: Double,
): Result<TtsJob>
suspend fun getJob(jobId: String): Result<TtsJob>
suspend fun downloadAudio(jobId: String): Result<ByteArray>
}

View file

@ -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<ReaderText>,
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<ReaderPage> {
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<ReaderPage>()
val pageItems = mutableListOf<ReaderText>()
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<ReaderText>,
metrics: List<BlockMetrics>,
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<AnnotatedString, AnnotatedString>? {
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<Float> = 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
}
}

View file

@ -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<SearchResponse> =
repository.search(request)
}

View file

@ -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<DownloadsListResponse> =
repository.listDownloads()
}

View file

@ -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<DownloadResponse> =
repository.startDownload(request)
}

View file

@ -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<Unit> {
if (book.remoteId.isBlank()) {
return Result.success(Unit)
}
return progressRepository.syncPlaybackProgress(
book = book,
currentFile = currentFile,
position = position,
duration = duration,
)
}
}

View file

@ -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<Unit> {
if (book.remoteId.isBlank()) {
return Result.success(Unit)
}
return progressRepository.syncReadingProgress(book, lastChapter)
}
}

View file

@ -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<String> {
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"
}
}
}

View file

@ -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<TtsJob> = repository.createJob(bookId, engine, voiceId, speed)
}

View file

@ -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<List<TtsEngine>> = repository.fetchEngines()
}

View file

@ -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<List<TtsVoice>> =
repository.fetchVoices(engine)
}

View file

@ -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<TtsJob> = repository.getJob(jobId)
}

View file

@ -20,19 +20,14 @@ fun String.isValidUri(): Boolean {
/** /**
* Returns true if the string starts with http:// or https:// (case-insensitive). * Returns true if the string starts with http:// or https:// (case-insensitive).
*/ */
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this) fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX)
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
/** /**
* 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 { fun String.ensureUriScheme(): String {
val trimmed = trim() val trimmed = trim()
if (trimmed.hasUriScheme()) return trimmed return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
val hostPart = trimmed.substringBefore("/")
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
return "$scheme$trimmed"
} }
/** /**

View file

@ -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"
}

View file

@ -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<AudiobooksModel>()
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.
}
)
}
}

View file

@ -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"
}

View file

@ -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<BooksModel>()
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))
}
)
}
}

View file

@ -6,9 +6,23 @@
package org.dueattendant149.bookshelf.presentation.bookshelf package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.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.CacheBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase 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.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 org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import javax.inject.Inject import javax.inject.Inject
data class RemoteLibraryState(
val libraries: List<RemoteLibrary> = emptyList(),
val books: List<Book> = emptyList(),
val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false,
val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
val offlineOnly: Boolean = false,
val cachingBookId: String? = null,
)
@HiltViewModel @HiltViewModel
class RemoteLibraryModel class RemoteLibraryModel
@Inject @Inject
constructor( constructor(
fetchLibrariesUseCase: FetchLibrariesUseCase, private val fetchLibrariesUseCase: FetchLibrariesUseCase,
fetchBooksUseCase: FetchBooksUseCase, private val fetchBooksUseCase: FetchBooksUseCase,
cacheBookUseCase: CacheBookUseCase, private val cacheBookUseCase: CacheBookUseCase,
deleteCacheUseCase: DeleteCacheUseCase, private val deleteCacheUseCase: DeleteCacheUseCase,
getCacheStatusUseCase: GetCacheStatusUseCase, private val getCacheStatusUseCase: GetCacheStatusUseCase,
serverSettings: ServerSettings, private val serverSettings: ServerSettings,
serverStatusMonitor: ServerStatusMonitor, ) : ViewModel() {
) : RemoteLibraryViewModel( private val _state = MutableStateFlow(RemoteLibraryState())
fetchLibrariesUseCase, val state = _state.asStateFlow()
fetchBooksUseCase, private var cacheStatusJob: Job? = null
cacheBookUseCase,
deleteCacheUseCase, private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
getCacheStatusUseCase, val effects = _effects.asSharedFlow()
serverSettings,
serverStatusMonitor, init {
) { loadLibraries()
override val libraryNameFilter: String? = null }
override val itemTypeFilter: String? = null
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) }
}
}
}
} }

View file

@ -12,12 +12,9 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen 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.reader.ReaderScreen
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@ -40,15 +37,6 @@ object RemoteLibraryScreen : Screen, Parcelable {
} }
} }
RemoteLibraryContent( RemoteLibraryContent(model = model)
model = model,
titleRes = R.string.bookshelf_screen,
onPlayAudio = { book ->
navigator.push(PlayerScreen(book))
},
navigateToTts = { bookRemoteId ->
navigator.push(TtsScreen(bookRemoteId))
}
)
} }
} }

View file

@ -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<RemoteLibrary> = emptyList(),
val books: List<Book> = emptyList(),
val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false,
val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = 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<RemoteLibraryEffect>()
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<Book>) {
// 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) }
}
}
}
}

View file

@ -24,10 +24,13 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.data.settings.SettingsManager import org.dueattendant149.bookshelf.data.settings.SettingsManager
import org.dueattendant149.bookshelf.presentation.bookshelf.BooksScreen import org.dueattendant149.bookshelf.presentation.browse.BrowseModel
import org.dueattendant149.bookshelf.presentation.bookshelf.AudiobooksScreen import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen
import org.dueattendant149.bookshelf.presentation.search.SearchScreen import org.dueattendant149.bookshelf.presentation.history.HistoryModel
import org.dueattendant149.bookshelf.presentation.settings.SettingsScreen 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.NavigatorItem
import org.dueattendant149.bookshelf.presentation.navigator.StackEvent import org.dueattendant149.bookshelf.presentation.navigator.StackEvent
import org.dueattendant149.bookshelf.presentation.settings.SettingsModel import org.dueattendant149.bookshelf.presentation.settings.SettingsModel
@ -74,6 +77,11 @@ class MainActivity : AppCompatActivity() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
// Initializing Screen Models
val libraryModel = hiltViewModel<LibraryModel>()
val historyModel = hiltViewModel<HistoryModel>()
val browseModel = hiltViewModel<BrowseModel>()
SettingsEffects( SettingsEffects(
effects = settingsModel.effects effects = settingsModel.effects
) )
@ -81,32 +89,32 @@ class MainActivity : AppCompatActivity() {
ProvideSettings(settings) { ProvideSettings(settings) {
val tabs = persistentListOf( val tabs = persistentListOf(
NavigatorItem( NavigatorItem(
screen = BooksScreen, screen = LibraryScreen,
title = R.string.books_screen, title = R.string.library_screen,
tooltip = R.string.books_content_desc, tooltip = R.string.library_content_desc,
selectedIcon = R.drawable.book_tab_filled, selectedIcon = R.drawable.library_screen_filled,
unselectedIcon = R.drawable.book_tab_outlined unselectedIcon = R.drawable.library_screen_outlined
), ),
NavigatorItem( NavigatorItem(
screen = AudiobooksScreen, screen = HistoryScreen,
title = R.string.audiobooks_screen, title = R.string.history_screen,
tooltip = R.string.audiobooks_content_desc, tooltip = R.string.history_content_desc,
selectedIcon = R.drawable.audiobook_tab_filled, selectedIcon = R.drawable.history_screen_filled,
unselectedIcon = R.drawable.audiobook_tab_outlined unselectedIcon = R.drawable.history_screen_outlined
), ),
NavigatorItem( NavigatorItem(
screen = SearchScreen, screen = BrowseScreen,
title = R.string.search_screen, title = R.string.browse_screen,
tooltip = R.string.search_content_desc_network, tooltip = R.string.browse_content_desc,
selectedIcon = R.drawable.search_tab_filled, selectedIcon = R.drawable.browse_screen_filled,
unselectedIcon = R.drawable.search_tab_outlined unselectedIcon = R.drawable.browse_screen_outlined
), ),
NavigatorItem( NavigatorItem(
screen = SettingsScreen, screen = RemoteLibraryScreen,
title = R.string.settings_screen, title = R.string.bookshelf_screen,
tooltip = R.string.settings_screen, tooltip = R.string.bookshelf_content_desc,
selectedIcon = R.drawable.settings_tab_filled, selectedIcon = R.drawable.bookshelf_screen_filled,
unselectedIcon = R.drawable.settings_tab_outlined unselectedIcon = R.drawable.bookshelf_screen_outlined
) )
) )
@ -121,7 +129,7 @@ class MainActivity : AppCompatActivity() {
) { ) {
Navigator( Navigator(
initialScreen = if (settings.showStartScreen.value) StartScreen initialScreen = if (settings.showStartScreen.value) StartScreen
else BooksScreen, else LibraryScreen,
transitionSpec = { lastEvent -> transitionSpec = { lastEvent ->
when (lastEvent) { when (lastEvent) {
StackEvent.DEFAULT -> { StackEvent.DEFAULT -> {
@ -137,14 +145,14 @@ class MainActivity : AppCompatActivity() {
}, },
contentKey = { contentKey = {
when (it) { when (it) {
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs" LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs"
else -> it else -> it
} }
}, },
backHandlerEnabled = { it != StartScreen } backHandlerEnabled = { it != StartScreen }
) { screen -> ) { screen ->
when (screen) { when (screen) {
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> { LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> {
NavigatorTabs( NavigatorTabs(
currentTab = screen, currentTab = screen,
transitionSpec = { transitionSpec = {

View file

@ -52,8 +52,7 @@ class Navigator @AssistedInject constructor(
popping: Boolean = false, popping: Boolean = false,
saveInBackStack: Boolean = true saveInBackStack: Boolean = true
) { ) {
val current = lastItem.value if (lastItem.value::class == targetScreen::class) return
if (current::class == targetScreen::class && current == targetScreen) return
if (!saveInBackStack) items.removeLast() if (!saveInBackStack) items.removeLast()
changeStackEvent( changeStackEvent(
@ -61,6 +60,7 @@ class Navigator @AssistedInject constructor(
else StackEvent.DEFAULT else StackEvent.DEFAULT
) )
if (lastItem.value::class == targetScreen::class) items.removeLast()
items.add(targetScreen) items.add(targetScreen)
} }

View file

@ -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<PlayerModel>()
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)
}
}

View file

@ -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<AudioTrack>, 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()
}
}

View file

@ -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<PlayerModel>()
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,
)
}
}

View file

@ -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<AudioTrack> = 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,
)

View file

@ -38,8 +38,4 @@ sealed class ReaderEffect {
data class OnNavigateToBookInfo( data class OnNavigateToBookInfo(
val changePath: Boolean val changePath: Boolean
) : ReaderEffect() ) : ReaderEffect()
data class OnNavigateToRsvp(
val bookId: Int
) : ReaderEffect()
} }

View file

@ -40,14 +40,6 @@ sealed class ReaderEvent {
val progress: Float val progress: Float
) : ReaderEvent() ) : ReaderEvent()
data class OnChangePage(
val page: Int
) : ReaderEvent()
data class OnPagesComputed(
val pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>
) : ReaderEvent()
data class OnRestoreCheckpoint( data class OnRestoreCheckpoint(
val checkpoint: Checkpoint val checkpoint: Checkpoint
) : ReaderEvent() ) : ReaderEvent()
@ -86,8 +78,4 @@ sealed class ReaderEvent {
data class OnNavigateToBookInfo( data class OnNavigateToBookInfo(
val changePath: Boolean val changePath: Boolean
) : ReaderEvent() ) : ReaderEvent()
data class OnNavigateToRsvp(
val bookId: Int
) : ReaderEvent()
} }

View file

@ -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.GetTextUseCase
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase 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.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.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
@ -52,9 +50,7 @@ class ReaderModel @Inject constructor(
private val getTextUseCase: GetTextUseCase, private val getTextUseCase: GetTextUseCase,
private val getBookUseCase: GetBookUseCase, private val getBookUseCase: GetBookUseCase,
private val getHistoryForBookUseCase: GetHistoryForBookUseCase, private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
private val getChapterProgressUseCase: GetChapterProgressUseCase, private val getChapterProgressUseCase: GetChapterProgressUseCase
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
) : ViewModel() { ) : ViewModel() {
private val mutex = Mutex() private val mutex = Mutex()
@ -98,9 +94,7 @@ class ReaderModel @Inject constructor(
book = it.book.copy( book = it.book.copy(
lastOpened = lastOpened lastOpened = lastOpened
), ),
text = text, text = text
pages = emptyList(),
currentPage = 0
) )
} }
ensureActive() ensureActive()
@ -115,34 +109,24 @@ class ReaderModel @Inject constructor(
} }
is ReaderEvent.OnRestoreScroll -> { is ReaderEvent.OnRestoreScroll -> {
val state = _state.value snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 }
if (state.pages.isNotEmpty()) {
val page = state.pages
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
.takeIf { it >= 0 }
?: 0
onEvent(ReaderEvent.OnChangePage(page))
_state.update { it.copy(isLoading = false, errorMessage = null) }
} else {
snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 }
state.listState.requestScrollToItem( _state.value.listState.requestScrollToItem(
index = state.book.scrollIndex, index = _state.value.book.scrollIndex,
scrollOffset = state.book.scrollOffset 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) updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
LibraryScreen.refreshListChannel.trySend(300) LibraryScreen.refreshListChannel.trySend(300)
HistoryScreen.refreshListChannel.trySend(300) HistoryScreen.refreshListChannel.trySend(300)
@ -244,69 +224,14 @@ class ReaderModel @Inject constructor(
scrollJob = viewModelScope.launch(Dispatchers.IO) { scrollJob = viewModelScope.launch(Dispatchers.IO) {
delay(300) delay(300)
val state = _state.value val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
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))
}
}
}
is ReaderEvent.OnChangePage -> { _state.value.listState.requestScrollToItem(
withContext(Dispatchers.Default) { index = scrollTo,
val state = _state.value scrollOffset = 0
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.update { onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
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) }
} }
} }
@ -361,10 +286,6 @@ class ReaderModel @Inject constructor(
} }
updateBookUseCase(_state.value.book) updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
LibraryScreen.refreshListChannel.trySend(0) LibraryScreen.refreshListChannel.trySend(0)
HistoryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0)
@ -453,10 +374,6 @@ class ReaderModel @Inject constructor(
is ReaderEvent.OnNavigateToBookInfo -> { is ReaderEvent.OnNavigateToBookInfo -> {
_effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath)) _effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath))
} }
is ReaderEvent.OnNavigateToRsvp -> {
_effects.emit(ReaderEffect.OnNavigateToRsvp(event.bookId))
}
} }
}.also { eventStack.add(it) } }.also { eventStack.add(it) }
} }
@ -531,10 +448,6 @@ class ReaderModel @Inject constructor(
} }
updateBookUseCase(_state.value.book) updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
LibraryScreen.refreshListChannel.trySend(0) LibraryScreen.refreshListChannel.trySend(0)
HistoryScreen.refreshListChannel.trySend(0) HistoryScreen.refreshListChannel.trySend(0)
@ -557,31 +470,22 @@ class ReaderModel @Inject constructor(
} ?: (-1 to -1) } ?: (-1 to -1)
} }
private fun calculateProgress(pageOrIndex: Int? = null): Float { private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
val state = _state.value
if ( if (
state.isLoading || _state.value.isLoading ||
state.text.isEmpty() || _state.value.listState.layoutInfo.totalItemsCount == 0 ||
state.errorMessage != null _state.value.text.isEmpty() ||
) return state.book.progress _state.value.errorMessage != null
) return _state.value.book.progress
if (state.pages.isNotEmpty()) { if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f
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 (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 return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex)
if (index == 0) return 0f .div(_state.value.text.lastIndex.toFloat())
.coerceAndPreventNaN()
val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
} }
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) { private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {

View file

@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.presentation.reader
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.os.Parcelable import android.os.Parcelable
import android.view.KeyEvent
import android.view.WindowManager import android.view.WindowManager
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -24,29 +23,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable 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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix 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.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp 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.common.helpers.setBrightness
import org.dueattendant149.bookshelf.ui.reader.ReaderContent import org.dueattendant149.bookshelf.ui.reader.ReaderContent
import org.dueattendant149.bookshelf.ui.reader.ReaderEffects import org.dueattendant149.bookshelf.ui.reader.ReaderEffects
import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Parcelize @Parcelize
@ -95,11 +83,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
) { ) {
state.value.listState state.value.listState
} }
val configuration = LocalConfiguration.current
val textMeasurer = rememberTextMeasurer()
val readerPagination = settings.readerPagination.value
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
derivedStateOf { derivedStateOf {
object : NestedScrollConnection { object : NestedScrollConnection {
@ -172,9 +155,76 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
) return@remember 0.sp ) return@remember 0.sp
(settings.paragraphIndentation.lastValue * 6).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) { val imagesWidth = remember(settings.imagesWidth.value) {
settings.imagesWidth.lastValue.coerceAtLeast(0.01f) 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) { val progressBarPadding = remember(settings.progressBarPadding.value) {
(settings.progressBarPadding.lastValue * 3).dp (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<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>(),
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) { val bottomBarPadding = remember(settings.bottomBarPadding.value) {
(settings.bottomBarPadding.lastValue * 4f).dp (settings.bottomBarPadding.lastValue * 4f).dp
} }
@ -438,9 +336,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
screenModel.init(bookId = bookId) screenModel.init(bookId = bookId)
} }
LaunchedEffect(pages) {
screenModel.onEvent(ReaderEvent.OnPagesComputed(pages))
}
LaunchedEffect(settings.fullscreen.value) { LaunchedEffect(settings.fullscreen.value) {
screenModel.onEvent( screenModel.onEvent(
ReaderEvent.OnMenuVisibility( ReaderEvent.OnMenuVisibility(
@ -477,45 +372,11 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} }
onDispose { onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) 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) { DisposableEffect(Unit) {
onDispose { onDispose {
screenModel.clearAsync() screenModel.clearAsync()
@ -532,44 +393,32 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
fullscreen = settings.fullscreen.value fullscreen = settings.fullscreen.value
) )
Box( ReaderContent(
modifier = Modifier book = state.value.book,
.focusable() text = state.value.text,
) { bottomSheet = state.value.bottomSheet,
ReaderContent( drawer = state.value.drawer,
book = state.value.book, listState = listState,
text = state.value.text, currentChapter = state.value.currentChapter,
bottomSheet = state.value.bottomSheet, nestedScrollConnection = nestedScrollConnection.value,
drawer = state.value.drawer, fastColorPresetChange = settings.fastColorPresetChange.value,
listState = listState, perceptionExpander = settings.perceptionExpander.value,
currentChapter = state.value.currentChapter, perceptionExpanderPadding = perceptionExpanderPadding,
nestedScrollConnection = nestedScrollConnection.value, perceptionExpanderThickness = perceptionExpanderThickness,
fastColorPresetChange = settings.fastColorPresetChange.value, horizontalLimiter = settings.horizontalLimiter.value,
perceptionExpander = settings.perceptionExpander.value, horizontalLimiterHeight = horizontalLimiterHeight,
perceptionExpanderPadding = perceptionExpanderPadding, horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
perceptionExpanderThickness = perceptionExpanderThickness, horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
horizontalLimiter = settings.horizontalLimiter.value, horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
horizontalLimiterHeight = horizontalLimiterHeight, horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value, currentChapterProgress = state.value.currentChapterProgress,
horizontalLimiterRuler = settings.horizontalLimiterRuler.value, isLoading = state.value.isLoading,
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, errorMessage = state.value.errorMessage,
horizontalLimiterDimming = settings.horizontalLimiterDimming.value, checkpoints = state.value.checkpoints,
currentChapterProgress = state.value.currentChapterProgress, showMenu = state.value.showMenu,
isLoading = state.value.isLoading, lockMenu = state.value.lockMenu,
errorMessage = state.value.errorMessage, contentPadding = contentPadding,
checkpoints = state.value.checkpoints, verticalPadding = verticalPadding,
showMenu = state.value.showMenu,
lockMenu = state.value.lockMenu,
readerPagination = readerPagination,
pages = state.value.pages,
currentPage = state.value.currentPage,
onPageChanged = { page ->
if (state.value.pages.isNotEmpty()) {
screenModel.onEvent(ReaderEvent.OnChangePage(page))
}
},
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = settings.horizontalGesture.value, horizontalGesture = settings.horizontalGesture.value,
horizontalGestureScroll = settings.horizontalGestureScroll.value, horizontalGestureScroll = settings.horizontalGestureScroll.value,
horizontalGestureSensitivity = horizontalGestureSensitivity, horizontalGestureSensitivity = horizontalGestureSensitivity,
@ -621,9 +470,7 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
showChaptersDrawer = screenModel::onEvent, showChaptersDrawer = screenModel::onEvent,
dismissDrawer = screenModel::onEvent, dismissDrawer = screenModel::onEvent,
navigateBack = screenModel::onEvent, navigateBack = screenModel::onEvent,
navigateToBookInfo = screenModel::onEvent, navigateToBookInfo = screenModel::onEvent
navigateToRsvp = screenModel::onEvent
) )
}
} }
} }

View file

@ -12,7 +12,6 @@ import org.dueattendant149.bookshelf.core.BottomSheet
import org.dueattendant149.bookshelf.core.Drawer import org.dueattendant149.bookshelf.core.Drawer
import org.dueattendant149.bookshelf.core.ui.UIText import org.dueattendant149.bookshelf.core.ui.UIText
import org.dueattendant149.bookshelf.domain.model.library.Book 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
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
@ -21,8 +20,6 @@ import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
data class ReaderState( data class ReaderState(
val book: Book = Book.default, val book: Book = Book.default,
val text: List<ReaderText> = emptyList(), val text: List<ReaderText> = emptyList(),
val pages: List<ReaderPage> = emptyList(),
val currentPage: Int = 0,
val listState: LazyListState = LazyListState(), val listState: LazyListState = LazyListState(),
val currentChapter: Chapter? = null, val currentChapter: Chapter? = null,

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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<RsvpEffect>()
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()
}
}

View file

@ -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<RsvpModel>()
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)
}
}

View file

@ -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,
)

View file

@ -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<SearchResultItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val downloadingTitle: String? = null,
val downloadSuccess: String? = null,
val downloadProgresses: Map<String, DownloadStatusResponse> = 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()
}
}

View file

@ -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<SearchModel>()
SearchContent(model = model)
}
}

View file

@ -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.CheckHealthUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl 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 import javax.inject.Inject
data class BookshelfSettingsState( data class BookshelfSettingsState(
@ -110,20 +108,8 @@ class BookshelfSettingsModel
fun save() { fun save() {
viewModelScope.launch { viewModelScope.launch {
val current = _state.value val current = _state.value
val normalizedBookshelfUrl = current.bookshelfUrl.trim().let { serverSettings.setBookshelfUrl(current.bookshelfUrl)
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it serverSettings.setAbsUrl(current.absUrl)
}
val normalizedAbsUrl = current.absUrl.trim().let {
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
}
_state.update {
it.copy(
bookshelfUrl = normalizedBookshelfUrl,
absUrl = normalizedAbsUrl,
)
}
serverSettings.setBookshelfUrl(normalizedBookshelfUrl)
serverSettings.setAbsUrl(normalizedAbsUrl)
serverSettings.setAbsToken(current.absToken) serverSettings.setAbsToken(current.absToken)
} }
} }

View file

@ -10,7 +10,6 @@ import android.os.Parcelable
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.navigator.Screen import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen
@ -30,12 +29,9 @@ object SettingsScreen : Screen, Parcelable {
val settings = LocalSettings.current val settings = LocalSettings.current
val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior() val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior()
val isRoot = navigator.items.collectAsStateWithLifecycle().value.size <= 1
SettingsContent( SettingsContent(
listState = listState, listState = listState,
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
isRoot = isRoot,
navigateToGeneralSettings = { navigateToGeneralSettings = {
navigator.push(GeneralSettingsScreen) navigator.push(GeneralSettingsScreen)
}, },

View file

@ -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<TtsEngine> = emptyList(),
val voices: List<TtsVoice> = 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<WorkInfo?>? = null
private var workObserver: Observer<WorkInfo?>? = 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<TtsDownloadWorker>()
.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<WorkInfo?> = 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()
}
}

View file

@ -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<TtsModel>()
val navigator = LocalNavigator.current
TtsContent(
model = model,
bookRemoteId = bookRemoteId,
navigateBack = navigator::pop,
)
}
}

View file

@ -8,25 +8,35 @@ package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue 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.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState 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.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api 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.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -40,62 +50,35 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.cache.CacheState 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.domain.model.library.Book
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder 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) @OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun RemoteLibraryContent( fun RemoteLibraryContent(model: RemoteLibraryModel) {
model: RemoteLibraryViewModel,
titleRes: Int,
onPlayAudio: (Book) -> Unit,
navigateToTts: (bookRemoteId: String) -> Unit,
) {
val state by model.state.collectAsStateWithLifecycle() val state by model.state.collectAsStateWithLifecycle()
val navigator = LocalNavigator.current
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.isLoading, refreshing = state.isLoading,
onRefresh = model::loadLibraries onRefresh = model::loadLibraries
) )
val sortedBooks = remember(state.books) { val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
state.books.sortedByDescending { it.lastOpened ?: 0L } if (!state.offlineOnly) state.books
} else state.books.filter { book ->
val status = state.cacheStatuses[book.remoteId]
val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) { status?.state == CacheState.COMPLETED ||
if (!state.offlineOnly) sortedBooks status?.state == CacheState.DOWNLOADING ||
else sortedBooks.filter { book -> status?.state == CacheState.PENDING
when (state.cacheStatuses[book.remoteId]?.state) {
CacheState.COMPLETED,
CacheState.DOWNLOADING,
CacheState.PENDING -> true
else -> false
}
} }
} }
val onlineIndicator = remember(state.serverOnline) {
if (state.serverOnline) ""
else " • Offline"
}
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = { Text(stringResource(R.string.bookshelf_screen)) }
Text(
text = stringResource(titleRes) + onlineIndicator,
style = MaterialTheme.typography.titleLarge
)
}
) )
} }
) { padding -> ) { padding ->
@ -110,71 +93,95 @@ fun RemoteLibraryContent(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items( item {
items = visibleBooks, Text(
key = { it.remoteId } text = stringResource(R.string.bookshelf_libraries_header),
) { book -> style = MaterialTheme.typography.titleSmall,
val cacheStatus = state.cacheStatuses[book.remoteId] color = MaterialTheme.colorScheme.primary,
val isCaching = state.cachingBookId == book.remoteId modifier = Modifier.padding(bottom = 4.dp)
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
)
}
}
) )
} }
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 { item {
Box( Box(
modifier = Modifier 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( EmptyPlaceholder(
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
message = stringResource(R.string.bookshelf_empty_message), message = stringResource(R.string.bookshelf_empty_message),
@ -198,7 +205,6 @@ fun RemoteLibraryContent(
) )
} }
state.error?.let { error -> state.error?.let { error ->
ErrorPlaceholder( ErrorPlaceholder(
modifier = Modifier.align(Alignment.Center), 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)
}

View file

@ -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]
}

View file

@ -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)
}
}

View file

@ -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<PlayerState>,
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)
}
}

View file

@ -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.common.model.Direction
import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition
import org.dueattendant149.bookshelf.ui.theme.readerBarsColor import org.dueattendant149.bookshelf.ui.theme.readerBarsColor
import kotlin.math.roundToInt
@Composable @Composable
fun ReaderBottomBar( fun ReaderBottomBar(
@ -61,10 +60,6 @@ fun ReaderBottomBar(
lockMenu: Boolean, lockMenu: Boolean,
checkpoints: List<Checkpoint>, checkpoints: List<Checkpoint>,
bottomBarPadding: Dp, bottomBarPadding: Dp,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onChangePage: (Int) -> Unit,
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit, restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit, scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
@ -143,17 +138,13 @@ fun ReaderBottomBar(
} }
}, },
slider = { slider = {
ReaderBottomBarSlider( ReaderBottomBarSlider(
book = book, book = book,
lockMenu = lockMenu, lockMenu = lockMenu,
listState = listState, listState = listState,
readerPagination = readerPagination, scroll = scroll,
pages = pages, changeProgress = changeProgress
currentPage = currentPage, )
onChangePage = onChangePage,
scroll = scroll,
changeProgress = changeProgress
)
}, },
indicator = { indicator = {
ReaderBottomBarCheckpointsIndicator( ReaderBottomBarCheckpointsIndicator(
@ -249,10 +240,6 @@ private fun ReaderBottomBarSlider(
book: Book, book: Book,
lockMenu: Boolean, lockMenu: Boolean,
listState: LazyListState, listState: LazyListState,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onChangePage: (Int) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit, scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
) { ) {
@ -268,17 +255,7 @@ private fun ReaderBottomBarSlider(
value = progress, value = progress,
enabled = !lockMenu, enabled = !lockMenu,
onValueChange = { onValueChange = {
if (readerPagination && pages.isNotEmpty()) { if (listState.layoutInfo.totalItemsCount > 0) {
val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex)
onChangePage(page)
changeProgress(
ReaderEvent.OnChangeProgress(
progress = it,
firstVisibleItemIndex = pages[page].startTextIndex,
firstVisibleItemOffset = 0
)
)
} else if (listState.layoutInfo.totalItemsCount > 0) {
scroll(ReaderEvent.OnScroll(it)) scroll(ReaderEvent.OnScroll(it))
changeProgress( changeProgress(
ReaderEvent.OnChangeProgress( ReaderEvent.OnChangeProgress(

View file

@ -58,10 +58,6 @@ fun ReaderContent(
checkpoints: List<Checkpoint>, checkpoints: List<Checkpoint>,
showMenu: Boolean, showMenu: Boolean,
lockMenu: Boolean, lockMenu: Boolean,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onPageChanged: (Int) -> Unit,
contentPadding: PaddingValues, contentPadding: PaddingValues,
verticalPadding: Dp, verticalPadding: Dp,
horizontalGesture: ReaderHorizontalGesture, horizontalGesture: ReaderHorizontalGesture,
@ -115,7 +111,6 @@ fun ReaderContent(
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit, showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit, dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit, navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) { ) {
ReaderBottomSheet( ReaderBottomSheet(
@ -125,33 +120,29 @@ fun ReaderContent(
) )
if (isLoading || errorMessage == null) { if (isLoading || errorMessage == null) {
ReaderScaffold( ReaderScaffold(
book = book, book = book,
text = text, text = text,
listState = listState, listState = listState,
currentChapter = currentChapter, currentChapter = currentChapter,
nestedScrollConnection = nestedScrollConnection, nestedScrollConnection = nestedScrollConnection,
fastColorPresetChange = fastColorPresetChange, fastColorPresetChange = fastColorPresetChange,
perceptionExpander = perceptionExpander, perceptionExpander = perceptionExpander,
perceptionExpanderPadding = perceptionExpanderPadding, perceptionExpanderPadding = perceptionExpanderPadding,
perceptionExpanderThickness = perceptionExpanderThickness, perceptionExpanderThickness = perceptionExpanderThickness,
horizontalLimiter = horizontalLimiter, horizontalLimiter = horizontalLimiter,
horizontalLimiterHeight = horizontalLimiterHeight, horizontalLimiterHeight = horizontalLimiterHeight,
horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset, horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
horizontalLimiterRuler = horizontalLimiterRuler, horizontalLimiterRuler = horizontalLimiterRuler,
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
horizontalLimiterDimming = horizontalLimiterDimming, horizontalLimiterDimming = horizontalLimiterDimming,
currentChapterProgress = currentChapterProgress, currentChapterProgress = currentChapterProgress,
isLoading = isLoading, isLoading = isLoading,
checkpoints = checkpoints, checkpoints = checkpoints,
showMenu = showMenu, showMenu = showMenu,
lockMenu = lockMenu, lockMenu = lockMenu,
readerPagination = readerPagination, contentPadding = contentPadding,
pages = pages, verticalPadding = verticalPadding,
currentPage = currentPage,
onPageChanged = onPageChanged,
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = horizontalGesture, horizontalGesture = horizontalGesture,
horizontalGestureScroll = horizontalGestureScroll, horizontalGestureScroll = horizontalGestureScroll,
horizontalGestureSensitivity = horizontalGestureSensitivity, horizontalGestureSensitivity = horizontalGestureSensitivity,
@ -200,8 +191,7 @@ fun ReaderContent(
showSettingsBottomSheet = showSettingsBottomSheet, showSettingsBottomSheet = showSettingsBottomSheet,
showChaptersDrawer = showChaptersDrawer, showChaptersDrawer = showChaptersDrawer,
navigateBack = navigateBack, navigateBack = navigateBack,
navigateToBookInfo = navigateToBookInfo, navigateToBookInfo = navigateToBookInfo
navigateToRsvp = navigateToRsvp
) )
} else { } else {
ReaderErrorPlaceholder( ReaderErrorPlaceholder(

View file

@ -19,7 +19,6 @@ import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen
import org.dueattendant149.bookshelf.presentation.reader.ReaderEffect 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.LocalActivity
import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
@ -184,10 +183,6 @@ fun ReaderEffects(
saveInBackStack = false saveInBackStack = false
) )
} }
is ReaderEffect.OnNavigateToRsvp -> {
navigator.push(RsvpScreen(bookId = effect.bookId))
}
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show more