Compare commits
13 commits
c8c1503d8c
...
f7307f4199
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7307f4199 | ||
|
|
71537406c6 | ||
|
|
a7a9f65391 | ||
|
|
efbd2aff66 | ||
|
|
8405f18f5f | ||
|
|
6ddbb01973 | ||
|
|
ccfc677ea7 | ||
|
|
37dfd74fc9 | ||
|
|
f54ed847f7 | ||
|
|
abe1a3db14 | ||
|
|
acff930d7b | ||
|
|
a6f8b6782a | ||
|
|
a8a1417b9d |
136 changed files with 9633 additions and 1253 deletions
46
AGENTS.md
46
AGENTS.md
|
|
@ -13,7 +13,7 @@
|
|||
- **Аудио:** ExoPlayer (Media3) + MediaSession
|
||||
- **Фоновые задачи:** WorkManager
|
||||
- **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark)
|
||||
- **minSdk:** 26 (план — поднять до 31 позже)
|
||||
- **minSdk:** 31
|
||||
- **compileSdk/targetSdk:** 36
|
||||
|
||||
## Goal
|
||||
|
|
@ -66,14 +66,46 @@
|
|||
|
||||
- Phase 1: fork + package rename completed.
|
||||
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
|
||||
- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed.
|
||||
- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17→18) completed.
|
||||
- Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
|
||||
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
||||
- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged.
|
||||
- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged.
|
||||
- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model.
|
||||
- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`.
|
||||
- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync.
|
||||
- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) completed.
|
||||
- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) completed.
|
||||
- Phase 6: feature integration on `feature/ui-polish` completed:
|
||||
- RemoteLibrary cache UI (offline-only filter, download/delete, progress indicator).
|
||||
- Remote ebook opening in the existing reader.
|
||||
- Audiobook player with ExoPlayer + MediaSession.
|
||||
- TTS jobs screen via bookshelf-api.
|
||||
- Reading and playback progress sync with periodic WorkManager worker.
|
||||
- Current: `feature/ui-polish` builds and produces `app-debug.apk` (~58 MB). Debug APK uploaded as `KRait-debug-15.apk` to ownCloud + caddy download site.
|
||||
- Polished: PlayerContent and TtsContent labels moved to `strings.xml`; TTS WorkManager observer leak fixed (`TtsModel` removes observer in `onCleared`).
|
||||
- Raised `minSdk` from 26 to 31.
|
||||
- Phase 7 (nav redesign): `MainActivity` tabs = Книги | Аудиокниги | Поиск | Настройки.
|
||||
- `BooksModel` / `AudiobooksModel` extend `RemoteLibraryViewModel` with `mediaTypeFilter`.
|
||||
- Library filter uses name fallback ("Books" / "Audiobooks") because ABS returns `mediaType: "book"` for both.
|
||||
- `SearchModel` + `SearchContent` for global Librarr search (`/api/v1/search`).
|
||||
- `ServerStatusMonitor` (singleton, pings `/health` every 30 s) → auto offline-only when down.
|
||||
- `SettingsScreen` is a root tab — back button hidden when stack size ≤ 1.
|
||||
- Phase 8 (RSVP speed reading): core tokenizer + engine + UI.
|
||||
- `domain/model/rsvp/` — `RsvpToken`, `OrpTable`, `RsvpTokenizer`, `RsvpEngine`.
|
||||
- `presentation/rsvp/` — `RsvpScreen`, `RsvpModel`, `RsvpState/Event/Effect`.
|
||||
- `ui/rsvp/RsvpContent.kt` — word stage with prefix/pivot/suffix, focus guide ▲▼, controls bar.
|
||||
- `ReaderTopBar` got a ⚡ button → `RsvpScreen(bookId)`.
|
||||
- Persists reading progress on close via `UpdateBookUseCase`.
|
||||
- Settings: `rsvpWpm` (100–900), `rsvpFontSize` (24–96 sp), `rsvpPauseOnParagraph`, `rsvpPauseOnChapter`, `rsvpPauseOnLongWords`, `rsvpShowFocusGuide`.
|
||||
- Phase 9 (reader pagination + polish):
|
||||
- Kindle-style paginated reading mode with `HorizontalPager`.
|
||||
- Volume keys (Up/Down) mapped to page navigation via `VolumeKeyWindowCallback`.
|
||||
- Pagination measurement/render synchronization: exact page geometry, real inter-item spacing, image sizing, chapter title extras.
|
||||
- Algorithm improvements: `TextLayoutResult.getLineForVerticalPosition()` split point, better widow/orphan handling.
|
||||
- Phase 10 (bugfixes):
|
||||
- Fixed search crash caused by duplicate LazyColumn keys when search results have empty `guid`.
|
||||
- Fixed `BookCard` intrinsic-measurement crash risk by switching cover to fixed-height vertical variant.
|
||||
- Phase 11 (audio player hardening):
|
||||
- ExoPlayer streaming cache via `SimpleCache` + `CacheDataSource` (512 MB external cache).
|
||||
- Unified `AuthorizationInterceptor` for player, backed by `PlaybackAuthProvider` refreshed from `ServerSettings`.
|
||||
- Current APK: `KRait-debug-27.apk` (1.9.11) on `books.dueattendant149.org/download`.
|
||||
- Next: final APK rebuild after pushing to Forgejo.
|
||||
|
||||
## WARNs
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ plugins {
|
|||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("com.google.devtools.ksp")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("kotlin-parcelize")
|
||||
|
|
@ -16,10 +17,10 @@ android {
|
|||
// Default configuration
|
||||
defaultConfig {
|
||||
applicationId = "org.dueattendant149.bookshelf"
|
||||
minSdk = 26
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 14
|
||||
versionName = "1.8.0"
|
||||
versionCode = 27
|
||||
versionName = "1.9.11"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
|
@ -179,4 +180,14 @@ dependencies {
|
|||
|
||||
// Background work (cache, TTS sync)
|
||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||
implementation("androidx.hilt:hilt-work:1.2.0")
|
||||
|
||||
// Test
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
testImplementation("com.squareup.retrofit2:retrofit:2.11.0")
|
||||
testImplementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
|
||||
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
||||
}
|
||||
|
|
@ -0,0 +1,500 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 18,
|
||||
"identityHash": "989c23bd1fdbd69a7d65b94ae6cf381b",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BookEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "author",
|
||||
"columnName": "author",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "filePath",
|
||||
"columnName": "filePath",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "scrollIndex",
|
||||
"columnName": "scrollIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "scrollOffset",
|
||||
"columnName": "scrollOffset",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progress",
|
||||
"columnName": "progress",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "image",
|
||||
"columnName": "image",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "categories",
|
||||
"columnName": "categories",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'[]'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "remoteId",
|
||||
"columnName": "remoteId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "libraryId",
|
||||
"columnName": "libraryId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaType",
|
||||
"columnName": "mediaType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasAudio",
|
||||
"columnName": "hasAudio",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasEbook",
|
||||
"columnName": "hasEbook",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "audioDuration",
|
||||
"columnName": "audioDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "audioCurrentFile",
|
||||
"columnName": "audioCurrentFile",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "audioCurrentPosition",
|
||||
"columnName": "audioCurrentPosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverUrl",
|
||||
"columnName": "coverUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncedAt",
|
||||
"columnName": "lastSyncedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HistoryEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "ColorPresetEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "backgroundColor",
|
||||
"columnName": "backgroundColor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fontColor",
|
||||
"columnName": "fontColor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSelected",
|
||||
"columnName": "isSelected",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "CategoryEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'LAST_READ'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrderDescending",
|
||||
"columnName": "sortOrderDescending",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "audio_file",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileId",
|
||||
"columnName": "fileId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remotePath",
|
||||
"columnName": "remotePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_audio_file_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "BookEntity",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bookId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "ebook_file",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileId",
|
||||
"columnName": "fileId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "format",
|
||||
"columnName": "format",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remotePath",
|
||||
"columnName": "remotePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ebook_file_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "BookEntity",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bookId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "cached_file",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remoteUrl",
|
||||
"columnName": "remoteUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progress",
|
||||
"columnName": "progress",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_cached_file_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_cached_file_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '989c23bd1fdbd69a7d65b94ae6cf381b')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 19,
|
||||
"identityHash": "7a70be4a2d1b3fd8f89dda543d18a6b4",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "BookEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `itemType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "author",
|
||||
"columnName": "author",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "filePath",
|
||||
"columnName": "filePath",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "scrollIndex",
|
||||
"columnName": "scrollIndex",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "scrollOffset",
|
||||
"columnName": "scrollOffset",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progress",
|
||||
"columnName": "progress",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "image",
|
||||
"columnName": "image",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "categories",
|
||||
"columnName": "categories",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'[]'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "remoteId",
|
||||
"columnName": "remoteId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "libraryId",
|
||||
"columnName": "libraryId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mediaType",
|
||||
"columnName": "mediaType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "itemType",
|
||||
"columnName": "itemType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasAudio",
|
||||
"columnName": "hasAudio",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasEbook",
|
||||
"columnName": "hasEbook",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "audioDuration",
|
||||
"columnName": "audioDuration",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "audioCurrentFile",
|
||||
"columnName": "audioCurrentFile",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "audioCurrentPosition",
|
||||
"columnName": "audioCurrentPosition",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverUrl",
|
||||
"columnName": "coverUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "''"
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncedAt",
|
||||
"columnName": "lastSyncedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HistoryEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "time",
|
||||
"columnName": "time",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "ColorPresetEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "backgroundColor",
|
||||
"columnName": "backgroundColor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fontColor",
|
||||
"columnName": "fontColor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isSelected",
|
||||
"columnName": "isSelected",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "CategoryEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrder",
|
||||
"columnName": "sortOrder",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'LAST_READ'"
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortOrderDescending",
|
||||
"columnName": "sortOrderDescending",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "1"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "audio_file",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileId",
|
||||
"columnName": "fileId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remotePath",
|
||||
"columnName": "remotePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "duration",
|
||||
"columnName": "duration",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "order",
|
||||
"columnName": "order",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_audio_file_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "BookEntity",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bookId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "ebook_file",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileId",
|
||||
"columnName": "fileId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "format",
|
||||
"columnName": "format",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remotePath",
|
||||
"columnName": "remotePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_ebook_file_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "BookEntity",
|
||||
"onDelete": "CASCADE",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"bookId"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"tableName": "cached_file",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bookId",
|
||||
"columnName": "bookId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "type",
|
||||
"columnName": "type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "remoteUrl",
|
||||
"columnName": "remoteUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "localPath",
|
||||
"columnName": "localPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "status",
|
||||
"columnName": "status",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "progress",
|
||||
"columnName": "progress",
|
||||
"affinity": "REAL",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_cached_file_bookId",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"bookId"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||
},
|
||||
{
|
||||
"name": "index_cached_file_type",
|
||||
"unique": false,
|
||||
"columnNames": [
|
||||
"type"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a70be4a2d1b3fd8f89dda543d18a6b4')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,12 @@
|
|||
tools:node="remove"
|
||||
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
|
||||
android:name=".Application"
|
||||
android:allowBackup="false"
|
||||
|
|
@ -56,6 +62,16 @@
|
|||
android:theme="@style/BookStory"
|
||||
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 -->
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
|
|
|
|||
|
|
@ -7,13 +7,36 @@
|
|||
package org.dueattendant149.bookshelf
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||
import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class Application : Application() {
|
||||
class Application : Application(), Configuration.Provider {
|
||||
|
||||
@Inject
|
||||
lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
@Inject
|
||||
lateinit var workManager: WorkManager
|
||||
|
||||
@Inject
|
||||
lateinit var serverStatusMonitor: ServerStatusMonitor
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
||||
ProgressSyncWorker.schedule(workManager)
|
||||
serverStatusMonitor.start()
|
||||
}
|
||||
}
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ import kotlinx.collections.immutable.toPersistentList
|
|||
import org.dueattendant149.bookshelf.core.language.Language
|
||||
|
||||
object CoreData {
|
||||
val defaultLanguage = Language.fromLanguageTag(languageTag = "en") // English
|
||||
val defaultLanguage = Language.fromLanguageTag(languageTag = "ru") // Russian
|
||||
val languages = listOf(
|
||||
Language.fromLanguageTag(languageTag = "ru"), // Russian
|
||||
Language.fromLanguageTag(languageTag = "en"), // English
|
||||
Language.fromLanguageTag(languageTag = "uk"), // Ukrainian
|
||||
Language.fromLanguageTag(languageTag = "de"), // German
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ object AppModule {
|
|||
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
|
||||
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity
|
||||
DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables
|
||||
DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields
|
||||
DatabaseHelper.MANUAL_MIGRATION_18_19, // add itemType column
|
||||
).allowMainThreadQueries().build().also { database ->
|
||||
// Additional Migrations
|
||||
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParser
|
|||
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
|
||||
import org.dueattendant149.bookshelf.data.parser.text.TextParser
|
||||
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.AudiobookshelfRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
|
||||
|
|
@ -35,7 +36,10 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
|
|||
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.ProgressRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl
|
||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
||||
|
|
@ -43,7 +47,9 @@ import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
|
|||
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
|
|
@ -103,6 +109,24 @@ abstract class RepositoryModule {
|
|||
cacheRepositoryImpl: CacheRepositoryImpl
|
||||
): CacheRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindAudiobookshelfRepository(
|
||||
audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl
|
||||
): AudiobookshelfRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindRemoteTtsRepository(
|
||||
remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl
|
||||
): RemoteTtsRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProgressRepository(
|
||||
progressRepositoryImpl: ProgressRepositoryImpl
|
||||
): ProgressRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBookMapper(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ data class BookEntity(
|
|||
val libraryId: String = "",
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val mediaType: String = "",
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val itemType: String = "",
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val hasAudio: Boolean = false,
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
|
|
@ -39,6 +41,10 @@ data class BookEntity(
|
|||
@ColumnInfo(defaultValue = "0")
|
||||
val audioDuration: Long = 0L,
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val audioCurrentFile: String = "",
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val audioCurrentPosition: Long = 0L,
|
||||
@ColumnInfo(defaultValue = "")
|
||||
val coverUrl: String = "",
|
||||
@ColumnInfo(defaultValue = "0")
|
||||
val lastSyncedAt: Long = 0L,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ interface BookDao {
|
|||
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
|
||||
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
|
||||
|
||||
@Query("SELECT * FROM bookentity WHERE remoteId != ''")
|
||||
suspend fun findBooksWithRemoteId(): List<BookEntity>
|
||||
|
||||
@Delete
|
||||
suspend fun deleteBook(book: BookEntity): Int
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import java.io.File
|
|||
EbookFileEntity::class,
|
||||
CachedFileEntity::class,
|
||||
],
|
||||
version = 17,
|
||||
version = 19,
|
||||
autoMigrations = [
|
||||
AutoMigration(1, 2),
|
||||
AutoMigration(2, 3),
|
||||
|
|
@ -203,6 +203,25 @@ object DatabaseHelper {
|
|||
@DeleteTable("CategorySortEntity")
|
||||
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
|
||||
|
||||
val MANUAL_MIGRATION_18_19 = object : Migration(18, 19) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE BookEntity ADD COLUMN itemType TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE BookEntity ADD COLUMN audioCurrentFile TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
database.execSQL(
|
||||
"ALTER TABLE BookEntity ADD COLUMN audioCurrentPosition INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Add remote / bookshelf columns to BookEntity
|
||||
|
|
|
|||
|
|
@ -29,9 +29,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
|
|||
remoteId = book.remoteId,
|
||||
libraryId = book.libraryId,
|
||||
mediaType = book.mediaType,
|
||||
itemType = book.itemType,
|
||||
hasAudio = book.hasAudio,
|
||||
hasEbook = book.hasEbook,
|
||||
audioDuration = book.audioDuration,
|
||||
audioCurrentFile = book.audioCurrentFile,
|
||||
audioCurrentPosition = book.audioCurrentPosition,
|
||||
coverUrl = book.coverUrl,
|
||||
lastSyncedAt = book.lastSyncedAt,
|
||||
)
|
||||
|
|
@ -56,9 +59,12 @@ class BookMapperImpl @Inject constructor() : BookMapper {
|
|||
remoteId = bookEntity.remoteId,
|
||||
libraryId = bookEntity.libraryId,
|
||||
mediaType = bookEntity.mediaType,
|
||||
itemType = bookEntity.itemType,
|
||||
hasAudio = bookEntity.hasAudio,
|
||||
hasEbook = bookEntity.hasEbook,
|
||||
audioDuration = bookEntity.audioDuration,
|
||||
audioCurrentFile = bookEntity.audioCurrentFile,
|
||||
audioCurrentPosition = bookEntity.audioCurrentPosition,
|
||||
coverUrl = bookEntity.coverUrl,
|
||||
lastSyncedAt = bookEntity.lastSyncedAt,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.playback
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.presentation.main.MainActivity
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@OptIn(UnstableApi::class)
|
||||
class AudioPlaybackService : MediaSessionService() {
|
||||
|
||||
@Inject
|
||||
lateinit var exoPlayer: ExoPlayer
|
||||
|
||||
private var mediaSession: MediaSession? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
|
||||
mediaSession = MediaSession.Builder(this, exoPlayer)
|
||||
.setSessionActivity(sessionActivity)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent?.getParcelableExtra(EXTRA_BOOK)
|
||||
}
|
||||
|
||||
val notification = buildNotification(book)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
||||
return mediaSession
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
val player = mediaSession?.player ?: exoPlayer
|
||||
if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mediaSession?.run {
|
||||
player.release()
|
||||
release()
|
||||
}
|
||||
mediaSession = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.audio_playback_channel),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = getString(R.string.audio_playback_channel_description)
|
||||
}
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(book: Book?): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(book?.title ?: getString(R.string.app_name))
|
||||
.setContentText(book?.author?.getAsString() ?: "")
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_BOOK = "extra_book"
|
||||
private const val CHANNEL_ID = "audio_playback"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package org.dueattendant149.bookshelf.data.playback.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import java.io.File
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object CacheModule {
|
||||
|
||||
private const val CACHE_DIR_NAME = "audiobook_cache"
|
||||
private const val CACHE_SIZE_BYTES = 512L * 1024 * 1024 // 512 MB
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSimpleCache(
|
||||
@ApplicationContext context: Context,
|
||||
): SimpleCache {
|
||||
val cacheDir = File(context.externalCacheDir ?: context.cacheDir, CACHE_DIR_NAME)
|
||||
if (!cacheDir.exists()) {
|
||||
cacheDir.mkdirs()
|
||||
}
|
||||
return SimpleCache(
|
||||
cacheDir,
|
||||
LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES),
|
||||
StandaloneDatabaseProvider(context),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package org.dueattendant149.bookshelf.data.playback.di
|
||||
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* In-memory holder for the audiobookshelf bearer token used by playback
|
||||
* components. [refresh] must be called after the token changes or before a
|
||||
* fresh playback session starts.
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackAuthProvider @Inject constructor() {
|
||||
|
||||
@Volatile
|
||||
var token: String = ""
|
||||
private set
|
||||
|
||||
suspend fun refresh(serverSettings: ServerSettings) {
|
||||
token = serverSettings.getAbsToken() ?: ""
|
||||
}
|
||||
|
||||
fun setToken(value: String) {
|
||||
token = value.trim()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package org.dueattendant149.bookshelf.data.playback.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object PlaybackModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePlaybackAuthProvider(): PlaybackAuthProvider = PlaybackAuthProvider()
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideExoPlayer(
|
||||
@ApplicationContext context: Context,
|
||||
authProvider: PlaybackAuthProvider,
|
||||
okHttpClient: OkHttpClient,
|
||||
): ExoPlayer {
|
||||
val client = okHttpClient.newBuilder()
|
||||
.addInterceptor(AuthorizationInterceptor(authProvider::token))
|
||||
.build()
|
||||
|
||||
val dataSourceFactory = OkHttpDataSource.Factory(client)
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
|
||||
return ExoPlayer.Builder(context)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
||||
.setAudioAttributes(audioAttributes, true)
|
||||
.setHandleAudioBecomingNoisy(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.remote
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Adds an `Authorization: Bearer <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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
|
|
@ -34,4 +38,15 @@ interface AudiobookshelfApiService {
|
|||
suspend fun getProgress(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for updating media progress directly on an Audiobookshelf server.
|
||||
*
|
||||
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaybackProgressUpdateRequest(
|
||||
@SerialName("currentTime")
|
||||
val currentTime: Double,
|
||||
@SerialName("duration")
|
||||
val duration: Double,
|
||||
@SerialName("progress")
|
||||
val progress: Double,
|
||||
@SerialName("episodeId")
|
||||
val episodeId: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AbsItemResponse(
|
||||
val id: String = "",
|
||||
val media: AbsMedia? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AbsMedia(
|
||||
val metadata: AbsMediaMetadata? = null,
|
||||
@SerialName("audioFiles")
|
||||
val audioFiles: List<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,
|
||||
)
|
||||
|
|
@ -1,13 +1,17 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
||||
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.AudioTrackResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
||||
|
|
@ -15,15 +19,20 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesRe
|
|||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Multipart
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Streaming
|
||||
import retrofit2.http.POST
|
||||
|
||||
/**
|
||||
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
|
||||
|
|
@ -33,16 +42,54 @@ interface BookshelfApiService {
|
|||
|
||||
// Health
|
||||
@GET("health")
|
||||
suspend fun health(): Response<String>
|
||||
suspend fun health(): Response<Unit>
|
||||
|
||||
// Libraries / books
|
||||
@GET("api/v1/books/libraries")
|
||||
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}")
|
||||
suspend fun getBook(
|
||||
@Path("itemId") itemId: String,
|
||||
): Response<String>
|
||||
): Response<ResponseBody>
|
||||
|
||||
@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")
|
||||
suspend fun searchLibrary(
|
||||
|
|
@ -83,6 +130,13 @@ interface BookshelfApiService {
|
|||
@Path("jobId") jobId: String,
|
||||
): Response<ResponseBody>
|
||||
|
||||
@Multipart
|
||||
@POST("api/v1/upload/book")
|
||||
suspend fun uploadBook(
|
||||
@Part file: MultipartBody.Part,
|
||||
@Part("book_type") bookType: RequestBody,
|
||||
): Response<UploadBookResponse>
|
||||
|
||||
// Downloads / sources
|
||||
@POST("api/v1/download")
|
||||
suspend fun startDownload(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ServerStatusMonitor
|
||||
@Inject
|
||||
constructor(
|
||||
private val clientFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
) {
|
||||
private val _isOnline = MutableStateFlow(false)
|
||||
val isOnline = _isOnline.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var pingJob: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (pingJob != null) return
|
||||
pingJob = scope.launch {
|
||||
while (true) {
|
||||
val online = checkHealth()
|
||||
_isOnline.value = online
|
||||
delay(30_000L)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
pingJob?.cancel()
|
||||
pingJob = null
|
||||
}
|
||||
|
||||
private suspend fun checkHealth(): Boolean {
|
||||
val url = serverSettings.getBookshelfUrl() ?: return false
|
||||
val client = clientFactory.provideClient(url) ?: return false
|
||||
return runCatching {
|
||||
val response = client.health()
|
||||
response.isSuccessful
|
||||
}.onFailure {
|
||||
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class AudioTrackResponse(
|
||||
@SerialName("file_id")
|
||||
val fileId: String,
|
||||
val title: String = "",
|
||||
val duration: Double = 0.0,
|
||||
val size: Long = 0L,
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for updating the audio playback progress of a book on the bookshelf-api.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaybackProgressUpdateRequest(
|
||||
val itemId: String,
|
||||
val libraryId: String,
|
||||
@SerialName("current_file")
|
||||
val currentFile: String,
|
||||
@SerialName("current_position")
|
||||
val currentPosition: Long,
|
||||
val duration: Long,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Request body for updating the local reading progress of a book on the bookshelf-api.
|
||||
*/
|
||||
@Serializable
|
||||
data class ProgressUpdateRequest(
|
||||
val itemId: String,
|
||||
val libraryId: String,
|
||||
val scrollIndex: Int,
|
||||
val scrollOffset: Int,
|
||||
val progress: Float,
|
||||
@SerialName("last_chapter")
|
||||
val lastChapter: String? = null,
|
||||
@SerialName("updated_at")
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LibraryItemsResponse(
|
||||
val library: LibraryResponse = LibraryResponse(""),
|
||||
val items: List<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,
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UploadBookResponse(
|
||||
val success: Boolean = false,
|
||||
val filename: String = "",
|
||||
val type: String = "",
|
||||
val path: String = "",
|
||||
@SerialName("error")
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AudiobookshelfRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val clientFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
) : AudiobookshelfRepository {
|
||||
override suspend fun getAudioTracks(itemId: String): Result<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,4 +92,10 @@ class BookRepositoryImpl @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,22 +7,26 @@
|
|||
package org.dueattendant149.bookshelf.data.repository
|
||||
|
||||
import android.app.Application
|
||||
import android.webkit.URLUtil
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.ResponseBody
|
||||
import org.dueattendant149.bookshelf.data.local.dto.AudioFileEntity
|
||||
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
|
||||
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
|
||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -34,156 +38,275 @@ class CacheRepositoryImpl
|
|||
private val application: Application,
|
||||
private val database: BookDatabase,
|
||||
private val bookMapper: BookMapper,
|
||||
private val apiFactory: AudiobookshelfApiClientFactory,
|
||||
private val apiFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
private val audiobookshelfRepository: AudiobookshelfRepository,
|
||||
) : CacheRepository {
|
||||
|
||||
private val cachedFileDao by lazy { database.cachedFileDao }
|
||||
private val audioFileDao by lazy { database.audioFileDao }
|
||||
|
||||
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
require(book.hasEbook) { "Book has no ebook." }
|
||||
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
||||
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
||||
|
||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
val localBookId = existing?.id ?: insertLocalBook(book)
|
||||
|
||||
if (existing != null &&
|
||||
cachedEbook?.localPath != null &&
|
||||
File(cachedEbook.localPath).exists()
|
||||
) {
|
||||
if (existing.filePath != cachedEbook.localPath) {
|
||||
database.bookDao.updateBook(
|
||||
bookMapper.toBookEntity(
|
||||
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
|
||||
)
|
||||
)
|
||||
}
|
||||
return@withContext existing.id
|
||||
}
|
||||
|
||||
val url = serverSettings.getAbsUrl()
|
||||
val token = serverSettings.getAbsToken()
|
||||
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
||||
throw IllegalStateException("Audiobookshelf server is not configured.")
|
||||
}
|
||||
|
||||
val client = apiFactory.provideClient(url, token)
|
||||
?: throw IllegalStateException("Could not create Audiobookshelf client.")
|
||||
|
||||
val response = client.downloadBook(book.remoteId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Download failed: ${response.code()}")
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
?: throw IllegalStateException("Download response body is empty.")
|
||||
|
||||
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
|
||||
val fileName = guessFileName(response, book.remoteId)
|
||||
val file = File(ebooksDir, fileName)
|
||||
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val localBook = if (existing != null) {
|
||||
book.copy(
|
||||
id = existing.id,
|
||||
filePath = file.absolutePath,
|
||||
scrollIndex = existing.scrollIndex,
|
||||
scrollOffset = existing.scrollOffset,
|
||||
progress = existing.progress,
|
||||
categories = existing.categories,
|
||||
)
|
||||
} else {
|
||||
book.copy(filePath = file.absolutePath)
|
||||
}
|
||||
|
||||
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
|
||||
|
||||
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
?: throw IllegalStateException("Could not insert or update book.")
|
||||
|
||||
database.ebookFileDao.insert(
|
||||
EbookFileEntity(
|
||||
bookId = inserted.id,
|
||||
fileId = book.remoteId,
|
||||
format = file.extension,
|
||||
localPath = file.absolutePath,
|
||||
)
|
||||
)
|
||||
|
||||
cachedFileDao.insert(
|
||||
CachedFileEntity(
|
||||
bookId = inserted.id,
|
||||
type = "ebook",
|
||||
remoteUrl = "$url/api/items/${book.remoteId}/download",
|
||||
localPath = file.absolutePath,
|
||||
status = "completed",
|
||||
progress = 1f,
|
||||
)
|
||||
)
|
||||
|
||||
inserted.id
|
||||
if (book.hasEbook) {
|
||||
cacheEbook(book, localBookId)
|
||||
}
|
||||
|
||||
if (book.hasAudio) {
|
||||
cacheAudiobook(book, localBookId)
|
||||
}
|
||||
|
||||
localBookId
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it)
|
||||
}
|
||||
|
||||
private suspend fun insertLocalBook(book: Book): Int {
|
||||
database.bookDao.insertBook(bookMapper.toBookEntity(book))
|
||||
return database.bookDao.findBookByRemoteId(book.remoteId)?.id
|
||||
?: throw IllegalStateException("Could not insert book.")
|
||||
}
|
||||
|
||||
private suspend fun cacheEbook(book: Book, bookId: Int) {
|
||||
val existingEbook = database.ebookFileDao.getByBookId(bookId)
|
||||
if (existingEbook?.localPath != null && File(existingEbook.localPath).exists()) {
|
||||
updateBookFilePath(book, existingEbook.localPath)
|
||||
return
|
||||
}
|
||||
|
||||
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||
?: throw IllegalStateException("Book not found")
|
||||
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
||||
?: throw IllegalStateException("Bookshelf API URL is not configured.")
|
||||
val client = apiFactory.provideClient(bookshelfUrl)
|
||||
?: throw IllegalStateException("Could not create Bookshelf API client.")
|
||||
|
||||
val response = client.downloadEbook(book.remoteId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Ebook download failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.")
|
||||
|
||||
val file = saveFileToDir(
|
||||
body,
|
||||
dirName = "ebooks",
|
||||
fileName = guessFileName(response, book.remoteId)
|
||||
)
|
||||
|
||||
database.ebookFileDao.insert(
|
||||
EbookFileEntity(
|
||||
bookId = bookId,
|
||||
fileId = book.remoteId,
|
||||
format = file.extension,
|
||||
localPath = file.absolutePath,
|
||||
)
|
||||
)
|
||||
|
||||
cachedFileDao.insert(
|
||||
CachedFileEntity(
|
||||
bookId = bookId,
|
||||
type = "ebook",
|
||||
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook",
|
||||
localPath = file.absolutePath,
|
||||
status = "completed",
|
||||
progress = 1f,
|
||||
)
|
||||
)
|
||||
|
||||
updateBookFilePath(book, file.absolutePath)
|
||||
}
|
||||
|
||||
private suspend fun cacheAudiobook(book: Book, bookId: Int) {
|
||||
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
||||
val tracks = tracksResult.getOrThrow()
|
||||
if (tracks.isEmpty()) {
|
||||
Log.w("CacheRepository", "No audio tracks for ${book.remoteId}")
|
||||
return
|
||||
}
|
||||
|
||||
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
||||
?: throw IllegalStateException("Bookshelf API URL is not configured.")
|
||||
val client = apiFactory.provideClient(bookshelfUrl)
|
||||
?: throw IllegalStateException("Could not create Bookshelf API client.")
|
||||
|
||||
// Persist track metadata if missing.
|
||||
val existingTracks = audioFileDao.getByBookId(bookId)
|
||||
if (existingTracks.isEmpty()) {
|
||||
audioFileDao.insertAll(
|
||||
tracks.mapIndexed { index, track ->
|
||||
AudioFileEntity(
|
||||
bookId = bookId,
|
||||
fileId = track.fileId,
|
||||
remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
|
||||
duration = track.durationMs / 1000.0,
|
||||
order = index,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
tracks.forEachIndexed { index, track ->
|
||||
val cached = audioFileDao.getByBookId(bookId)
|
||||
.firstOrNull { it.fileId == track.fileId }
|
||||
if (cached?.localPath != null && File(cached.localPath).exists()) {
|
||||
updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath)
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading")
|
||||
val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}"
|
||||
val response = client.downloadAudioFile(book.remoteId, track.fileId)
|
||||
if (!response.isSuccessful) {
|
||||
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
|
||||
throw IllegalStateException("Audio track $index download failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
|
||||
val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
|
||||
|
||||
audioFileDao.update(
|
||||
(cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId))
|
||||
.copy(localPath = file.absolutePath)
|
||||
)
|
||||
|
||||
val overallProgress = (index + 1).toFloat() / tracks.size
|
||||
updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed")
|
||||
cachedFileDao.insert(
|
||||
CachedFileEntity(
|
||||
bookId = bookId,
|
||||
type = "audio_${track.fileId}",
|
||||
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
|
||||
localPath = file.absolutePath,
|
||||
status = "completed",
|
||||
progress = overallProgress,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateAudioTrackProgress(
|
||||
bookId: Int,
|
||||
fileId: String,
|
||||
progress: Float,
|
||||
localPath: String?,
|
||||
status: String = "completed",
|
||||
) {
|
||||
val type = "audio_$fileId"
|
||||
val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull()
|
||||
val entity = CachedFileEntity(
|
||||
id = existing?.id ?: 0,
|
||||
bookId = bookId,
|
||||
type = type,
|
||||
remoteUrl = existing?.remoteUrl ?: "",
|
||||
localPath = localPath ?: existing?.localPath,
|
||||
status = status,
|
||||
progress = progress,
|
||||
)
|
||||
if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
|
||||
}
|
||||
|
||||
private suspend fun updateBookFilePath(book: Book, path: String) {
|
||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
|
||||
if (existing.filePath != path) {
|
||||
database.bookDao.updateBook(
|
||||
bookMapper.toBookEntity(
|
||||
bookMapper.toBook(existing).copy(filePath = path)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File {
|
||||
val dir = File(application.filesDir, dirName).apply { mkdirs() }
|
||||
val file = File(dir, fileName)
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun guessAudioExtension(fileNameOrTitle: String): String {
|
||||
val name = fileNameOrTitle.lowercase()
|
||||
return when {
|
||||
name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b"
|
||||
name.endsWith(".mp3") -> "mp3"
|
||||
name.endsWith(".flac") -> "flac"
|
||||
name.endsWith(".ogg") -> "ogg"
|
||||
name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b"
|
||||
else -> "mp3"
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||
if (book != null) {
|
||||
val ebook = database.ebookFileDao.getByBookId(book.id)
|
||||
val cachedFiles = cachedFileDao.getByBookId(book.id)
|
||||
val audioFiles = audioFileDao.getByBookId(book.id)
|
||||
|
||||
ebook?.localPath?.let { File(it).delete() }
|
||||
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
||||
audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
||||
|
||||
database.ebookFileDao.deleteByBookId(book.id)
|
||||
cachedFileDao.deleteByBookId(book.id)
|
||||
audioFileDao.deleteByBookId(book.id)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e("CacheRepository", "deleteCache failed for $remoteId", it)
|
||||
}
|
||||
|
||||
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||
if (book == null) {
|
||||
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
|
||||
return@flow
|
||||
}
|
||||
|
||||
cachedFileDao.observeByBookId(book.id).collect { entities ->
|
||||
val files = entities.map {
|
||||
CachedFile(
|
||||
type = it.type,
|
||||
status = it.status,
|
||||
progress = it.progress,
|
||||
localPath = it.localPath
|
||||
)
|
||||
}
|
||||
val state = when {
|
||||
entities.isEmpty() -> CacheState.NONE
|
||||
entities.all { it.status == "completed" } -> CacheState.COMPLETED
|
||||
entities.any { it.status == "failed" } -> CacheState.FAILED
|
||||
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
||||
entities.any { it.status == "pending" } -> CacheState.PENDING
|
||||
else -> CacheState.NONE
|
||||
}
|
||||
val progress = if (entities.isEmpty()) {
|
||||
0f
|
||||
} else {
|
||||
entities.map { it.progress }.average().toFloat()
|
||||
}
|
||||
emit(CacheStatus(remoteId, state, progress, files))
|
||||
}
|
||||
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||
if (book == null) {
|
||||
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
|
||||
return@flow
|
||||
}
|
||||
|
||||
private fun guessFileName(response: retrofit2.Response<okhttp3.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 = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||
return if (name.contains(".")) name else "$remoteId.epub"
|
||||
cachedFileDao.observeByBookId(book.id).collect { entities ->
|
||||
val files = entities.map {
|
||||
CachedFile(
|
||||
type = it.type,
|
||||
status = it.status,
|
||||
progress = it.progress,
|
||||
localPath = it.localPath
|
||||
)
|
||||
}
|
||||
val state = when {
|
||||
entities.isEmpty() -> CacheState.NONE
|
||||
entities.all { it.status == "completed" } -> CacheState.COMPLETED
|
||||
entities.any { it.status == "failed" } -> CacheState.FAILED
|
||||
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
||||
entities.any { it.status == "pending" } -> CacheState.PENDING
|
||||
else -> CacheState.NONE
|
||||
}
|
||||
val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat()
|
||||
emit(CacheStatus(remoteId, state, progress, files))
|
||||
}
|
||||
}
|
||||
|
||||
private fun guessFileName(response: Response<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() } }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProgressRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val database: BookDatabase,
|
||||
private val bookMapper: BookMapper,
|
||||
private val clientFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
) : ProgressRepository {
|
||||
override suspend fun syncReadingProgress(
|
||||
book: Book,
|
||||
lastChapter: String?,
|
||||
): Result<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"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,9 +7,21 @@
|
|||
package org.dueattendant149.bookshelf.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||
|
|
@ -23,6 +35,8 @@ class RemoteLibraryRepositoryImpl
|
|||
constructor(
|
||||
private val clientFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
private val database: BookDatabase,
|
||||
private val bookMapper: BookMapper,
|
||||
) : RemoteLibraryRepository {
|
||||
override suspend fun getLibraries(): Result<List<RemoteLibrary>> {
|
||||
return runCatching {
|
||||
|
|
@ -38,7 +52,42 @@ class RemoteLibraryRepositoryImpl
|
|||
}
|
||||
|
||||
override suspend fun getBooks(libraryId: String): Result<List<Book>> {
|
||||
return searchLibrary(libraryId, "")
|
||||
return runCatching {
|
||||
val client = client() ?: return Result.success(emptyList())
|
||||
val response = client.getLibraryItems(libraryId)
|
||||
if (!response.isSuccessful) {
|
||||
return Result.failure(RuntimeException("Failed to load library items: ${response.code()}"))
|
||||
}
|
||||
|
||||
val body = response.body() ?: LibraryItemsResponse()
|
||||
val items = body.items.map { it.toBook() }
|
||||
|
||||
// Upsert each item into the local DB keyed by remoteId,
|
||||
// preserving local file path/progress if the book already exists.
|
||||
items.forEach { book ->
|
||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
val toStore = if (existing != null) {
|
||||
val existingBook = bookMapper.toBook(existing)
|
||||
book.copy(
|
||||
id = existingBook.id,
|
||||
filePath = existingBook.filePath,
|
||||
scrollIndex = existingBook.scrollIndex,
|
||||
scrollOffset = existingBook.scrollOffset,
|
||||
progress = existingBook.progress,
|
||||
categories = existingBook.categories,
|
||||
lastOpened = existingBook.lastOpened,
|
||||
)
|
||||
} else {
|
||||
book
|
||||
}
|
||||
database.bookDao.insertBook(bookMapper.toBookEntity(toStore))
|
||||
}
|
||||
|
||||
Result.success(items)
|
||||
}.getOrElse {
|
||||
Log.e("RemoteLibraryRepo", "getBooks failed", it)
|
||||
Result.failure(it)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun searchLibrary(
|
||||
|
|
@ -57,6 +106,59 @@ class RemoteLibraryRepositoryImpl
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun search(request: SearchRequest): Result<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 fun LibraryResponse.toDomain() =
|
||||
|
|
@ -67,7 +169,54 @@ class RemoteLibraryRepositoryImpl
|
|||
itemCount = itemCount,
|
||||
)
|
||||
|
||||
private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
|
||||
private suspend fun UnifiedItemResponse.toBook(): Book {
|
||||
val resolvedCoverUrl = resolveCoverUrl(coverUrl)
|
||||
return Book(
|
||||
id = 0,
|
||||
title = title,
|
||||
author = UIText.StringValue(
|
||||
author.takeIf { it.isNotBlank() }
|
||||
?: authors.filter { it.isNotBlank() }.joinToString(", ")
|
||||
.takeIf { it.isNotBlank() }
|
||||
?: ""
|
||||
),
|
||||
description = null,
|
||||
filePath = "",
|
||||
coverImage = null,
|
||||
scrollIndex = progress?.reading?.scrollIndex ?: 0,
|
||||
scrollOffset = progress?.reading?.scrollOffset ?: 0,
|
||||
progress = progress?.reading?.progress ?: 0f,
|
||||
lastOpened = null,
|
||||
categories = emptyList(),
|
||||
remoteId = id,
|
||||
libraryId = libraryId,
|
||||
mediaType = mediaType,
|
||||
itemType = type,
|
||||
hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid",
|
||||
hasEbook = type == "ebook" || type == "hybrid",
|
||||
audioDuration = progress?.playback?.duration ?: duration.toLong(),
|
||||
audioCurrentFile = progress?.playback?.currentFile ?: "",
|
||||
audioCurrentPosition = progress?.playback?.currentPosition ?: 0L,
|
||||
coverUrl = resolvedCoverUrl,
|
||||
lastSyncedAt = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun resolveCoverUrl(coverUrl: String): String {
|
||||
if (coverUrl.isBlank()) return ""
|
||||
val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl
|
||||
val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/')
|
||||
return when {
|
||||
coverUrl.startsWith("http://", ignoreCase = true) ||
|
||||
coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl
|
||||
|
||||
coverUrl.startsWith("/") -> "$base$coverUrl"
|
||||
|
||||
else -> "$base/$coverUrl"
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
|
||||
Book(
|
||||
id = 0,
|
||||
title = title,
|
||||
|
|
@ -83,6 +232,7 @@ class RemoteLibraryRepositoryImpl
|
|||
remoteId = id,
|
||||
libraryId = libraryId,
|
||||
mediaType = mediaType,
|
||||
itemType = mediaType,
|
||||
hasAudio = mediaType == "audiobook" || mediaType == "podcast",
|
||||
hasEbook = mediaType == "book",
|
||||
audioDuration = duration.toLong(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEngineResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoiceResponse
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class RemoteTtsRepositoryImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val clientFactory: BookshelfApiClientFactory,
|
||||
private val serverSettings: ServerSettings,
|
||||
) : RemoteTtsRepository {
|
||||
override suspend fun fetchEngines(): Result<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,
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.data.settings
|
|||
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import org.dueattendant149.bookshelf.data.local.data_store.DataStore
|
||||
import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
|
||||
import org.dueattendant149.bookshelf.domain.util.isValidUri
|
||||
import org.dueattendant149.bookshelf.domain.util.normalizeUri
|
||||
import javax.inject.Inject
|
||||
|
|
@ -47,11 +48,12 @@ class ServerSettings
|
|||
* or null when all credentials are present.
|
||||
*/
|
||||
suspend fun getMissingCredentialsError(): String? {
|
||||
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)
|
||||
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)?.trim()
|
||||
if (bookshelfUrl.isNullOrBlank()) {
|
||||
return "Bookshelf API URL is missing"
|
||||
}
|
||||
if (!bookshelfUrl.isValidUri()) {
|
||||
val normalizedBookshelfUrl = bookshelfUrl.ensureUriScheme()
|
||||
if (!normalizedBookshelfUrl.isValidUri()) {
|
||||
return "Bookshelf API URL is invalid: $bookshelfUrl"
|
||||
}
|
||||
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
|
||||
|
|
@ -67,8 +69,8 @@ class ServerSettings
|
|||
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
|
||||
return when {
|
||||
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
|
||||
!url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
|
||||
else -> Result.success(url.normalizeUri())
|
||||
!url.ensureUriScheme().isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
|
||||
else -> Result.success(url.ensureUriScheme().normalizeUri())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -80,6 +82,8 @@ class ServerSettings
|
|||
|
||||
private fun String.normalizeIfValid(): String? {
|
||||
val trimmed = trim()
|
||||
return if (trimmed.isValidUri()) trimmed.normalizeUri() else null
|
||||
if (trimmed.isBlank()) return null
|
||||
val withScheme = trimmed.ensureUriScheme()
|
||||
return if (withScheme.isValidUri()) withScheme.normalizeUri() else null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -180,6 +180,26 @@ class SettingsManager @Inject constructor(
|
|||
val perceptionExpanderThickness = setting<Int, Int>(
|
||||
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>(
|
||||
key = booleanPreferencesKey("horizontal_limiter"), default = false
|
||||
)
|
||||
|
|
@ -211,6 +231,9 @@ class SettingsManager @Inject constructor(
|
|||
key = doublePreferencesKey("screen_brightness"), default = 0.5f,
|
||||
serialize = { it.toDouble() }, deserialize = { it.toFloat() }
|
||||
)
|
||||
val readerPagination = setting<Boolean, Boolean>(
|
||||
key = booleanPreferencesKey("reader_pagination"), default = true
|
||||
)
|
||||
val horizontalGesture = setting<ReaderHorizontalGesture, String>(
|
||||
key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF,
|
||||
serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) }
|
||||
|
|
|
|||
|
|
@ -96,13 +96,14 @@ class CacheDownloadWorker(
|
|||
}
|
||||
|
||||
if (hasAudio) {
|
||||
downloadFile(
|
||||
downloadAudioTracks(
|
||||
database = database,
|
||||
bookId = bookId,
|
||||
type = "audio",
|
||||
remoteUrl = "$absUrl/api/items/$remoteId/download",
|
||||
localFile = File(bookDir, "audio"),
|
||||
) { client.downloadBook(remoteId) }
|
||||
absUrl = absUrl,
|
||||
remoteId = remoteId,
|
||||
bookDir = bookDir,
|
||||
client = client,
|
||||
)
|
||||
}
|
||||
|
||||
Result.success()
|
||||
|
|
@ -112,6 +113,65 @@ class CacheDownloadWorker(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadAudioTracks(
|
||||
database: BookDatabase,
|
||||
bookId: Int,
|
||||
absUrl: String,
|
||||
remoteId: String,
|
||||
bookDir: File,
|
||||
client: org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiService,
|
||||
) {
|
||||
val audioDir = File(bookDir, "audio").apply { mkdirs() }
|
||||
|
||||
val itemResponse = client.getItem(remoteId)
|
||||
if (!itemResponse.isSuccessful) {
|
||||
throw IOException("Failed to fetch item for audio tracks: ${itemResponse.code()}")
|
||||
}
|
||||
val item = itemResponse.body() ?: throw IOException("Empty item response")
|
||||
val audioFiles = item.media?.audioFiles.orEmpty()
|
||||
|
||||
if (audioFiles.isEmpty()) {
|
||||
Log.w(TAG, "No audio files found for book $bookId")
|
||||
return
|
||||
}
|
||||
|
||||
val totalTracks = audioFiles.size
|
||||
audioFiles.forEachIndexed { index, file ->
|
||||
val fileId = file.ino
|
||||
val trackFile = File(audioDir, "${index}_${fileId}")
|
||||
val record = getOrCreateRecord(
|
||||
database,
|
||||
bookId,
|
||||
"audio_${index}",
|
||||
"$absUrl/api/items/$remoteId/file/$fileId",
|
||||
)
|
||||
|
||||
if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) {
|
||||
return@forEachIndexed
|
||||
}
|
||||
|
||||
database.cachedFileDao.update(record.copy(status = "downloading", progress = index.toFloat() / totalTracks))
|
||||
|
||||
val response = client.downloadAudioFile(remoteId, fileId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Audio track $index download failed: ${response.code()}")
|
||||
}
|
||||
val body = response.body() ?: throw IOException("Empty audio response body")
|
||||
|
||||
body.byteStream().use { input ->
|
||||
trackFile.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
|
||||
database.cachedFileDao.update(
|
||||
record.copy(
|
||||
status = "completed",
|
||||
progress = (index + 1).toFloat() / totalTracks,
|
||||
localPath = trackFile.absolutePath,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadFile(
|
||||
database: BookDatabase,
|
||||
bookId: Int,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.worker
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Periodic worker that syncs reading and playback progress for all books
|
||||
* that have a remote (bookshelf-api) identifier.
|
||||
*/
|
||||
class ProgressSyncWorker(
|
||||
context: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(context, params) {
|
||||
companion object {
|
||||
const val WORK_NAME = "progress-sync-worker"
|
||||
|
||||
fun schedule(workManager: WorkManager) {
|
||||
val request =
|
||||
PeriodicWorkRequestBuilder<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"
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.data.worker
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import kotlinx.coroutines.delay
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import java.io.File
|
||||
|
||||
@HiltWorker
|
||||
class TtsDownloadWorker
|
||||
@AssistedInject
|
||||
constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val remoteTtsRepository: RemoteTtsRepository,
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val jobId = inputData.getString(KEY_JOB_ID)
|
||||
?: return Result.failure(errorData("Missing job ID"))
|
||||
val bookId = inputData.getString(KEY_BOOK_ID) ?: ""
|
||||
|
||||
val maxAttempts = inputData.getInt(KEY_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS)
|
||||
val pollIntervalMs = inputData.getLong(KEY_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS)
|
||||
|
||||
repeat(maxAttempts) { attempt ->
|
||||
val result = remoteTtsRepository.getJob(jobId)
|
||||
val job = result.getOrElse {
|
||||
Log.e(TAG, "Failed to poll job $jobId (attempt $attempt)", it)
|
||||
return Result.retry()
|
||||
}
|
||||
|
||||
setProgress(
|
||||
progressData(
|
||||
jobId = jobId,
|
||||
status = job.status,
|
||||
progress = job.progress,
|
||||
currentChapter = job.currentChapter,
|
||||
completedChapters = job.completedChapters,
|
||||
totalChapters = job.totalChapters,
|
||||
)
|
||||
)
|
||||
|
||||
when {
|
||||
job.isCompleted -> {
|
||||
val downloadResult = remoteTtsRepository.downloadAudio(jobId)
|
||||
val audioBytes = downloadResult.getOrElse {
|
||||
Log.e(TAG, "Failed to download audio for job $jobId", it)
|
||||
return Result.failure(errorData(it.message ?: "Download failed"))
|
||||
}
|
||||
|
||||
val outputFile = outputFile(bookId, jobId)
|
||||
outputFile.parentFile?.mkdirs()
|
||||
outputFile.writeBytes(audioBytes)
|
||||
|
||||
Log.i(TAG, "Saved TTS audio for job $jobId to ${outputFile.absolutePath}")
|
||||
return Result.success(
|
||||
successData(
|
||||
jobId = jobId,
|
||||
filePath = outputFile.absolutePath,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
job.isFailed -> {
|
||||
Log.e(TAG, "TTS job $jobId failed: ${job.error}")
|
||||
return Result.failure(errorData(job.error.ifBlank { "TTS job failed" }))
|
||||
}
|
||||
|
||||
else -> {
|
||||
delay(pollIntervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Result.failure(errorData("TTS job polling timed out"))
|
||||
}
|
||||
|
||||
private fun outputFile(bookId: String, jobId: String): File {
|
||||
val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
||||
val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
||||
val dir = File(applicationContext.cacheDir, "tts")
|
||||
return File(dir, "${safeBookId}_${safeJobId}.m4b")
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "TtsDownloadWorker"
|
||||
|
||||
const val KEY_JOB_ID = "job_id"
|
||||
const val KEY_BOOK_ID = "book_id"
|
||||
const val KEY_MAX_ATTEMPTS = "max_attempts"
|
||||
const val KEY_POLL_INTERVAL_MS = "poll_interval_ms"
|
||||
|
||||
const val KEY_OUTPUT_FILE_PATH = "output_file_path"
|
||||
const val KEY_OUTPUT_JOB_ID = "output_job_id"
|
||||
const val KEY_ERROR_MESSAGE = "error_message"
|
||||
|
||||
const val PROGRESS_STATUS = "progress_status"
|
||||
const val PROGRESS_PROGRESS = "progress_progress"
|
||||
const val PROGRESS_CURRENT_CHAPTER = "progress_current_chapter"
|
||||
const val PROGRESS_COMPLETED_CHAPTERS = "progress_completed_chapters"
|
||||
const val PROGRESS_TOTAL_CHAPTERS = "progress_total_chapters"
|
||||
|
||||
private const val DEFAULT_MAX_ATTEMPTS = 360
|
||||
private const val DEFAULT_POLL_INTERVAL_MS = 5_000L
|
||||
|
||||
fun createInputData(
|
||||
jobId: String,
|
||||
bookId: String,
|
||||
maxAttempts: Int = DEFAULT_MAX_ATTEMPTS,
|
||||
pollIntervalMs: Long = DEFAULT_POLL_INTERVAL_MS,
|
||||
): Data =
|
||||
Data.Builder()
|
||||
.putString(KEY_JOB_ID, jobId)
|
||||
.putString(KEY_BOOK_ID, bookId)
|
||||
.putInt(KEY_MAX_ATTEMPTS, maxAttempts)
|
||||
.putLong(KEY_POLL_INTERVAL_MS, pollIntervalMs)
|
||||
.build()
|
||||
|
||||
private fun progressData(
|
||||
jobId: String,
|
||||
status: String,
|
||||
progress: Double,
|
||||
currentChapter: String,
|
||||
completedChapters: Int,
|
||||
totalChapters: Int,
|
||||
): Data =
|
||||
Data.Builder()
|
||||
.putString(KEY_JOB_ID, jobId)
|
||||
.putString(PROGRESS_STATUS, status)
|
||||
.putDouble(PROGRESS_PROGRESS, progress)
|
||||
.putString(PROGRESS_CURRENT_CHAPTER, currentChapter)
|
||||
.putInt(PROGRESS_COMPLETED_CHAPTERS, completedChapters)
|
||||
.putInt(PROGRESS_TOTAL_CHAPTERS, totalChapters)
|
||||
.build()
|
||||
|
||||
private fun successData(jobId: String, filePath: String): Data =
|
||||
Data.Builder()
|
||||
.putString(KEY_OUTPUT_JOB_ID, jobId)
|
||||
.putString(KEY_OUTPUT_FILE_PATH, filePath)
|
||||
.build()
|
||||
|
||||
private fun errorData(message: String): Data =
|
||||
Data.Builder()
|
||||
.putString(KEY_ERROR_MESSAGE, message)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
|
@ -35,9 +35,12 @@ data class Book(
|
|||
val remoteId: String = "",
|
||||
val libraryId: String = "",
|
||||
val mediaType: String = "",
|
||||
val itemType: String = "",
|
||||
val hasAudio: Boolean = false,
|
||||
val hasEbook: Boolean = false,
|
||||
val audioDuration: Long = 0L,
|
||||
val audioCurrentFile: String = "",
|
||||
val audioCurrentPosition: Long = 0L,
|
||||
val coverUrl: String = "",
|
||||
val lastSyncedAt: Long = 0L,
|
||||
) : Parcelable {
|
||||
|
|
@ -57,9 +60,12 @@ data class Book(
|
|||
remoteId = "",
|
||||
libraryId = "",
|
||||
mediaType = "",
|
||||
itemType = "",
|
||||
hasAudio = false,
|
||||
hasEbook = false,
|
||||
audioDuration = 0L,
|
||||
audioCurrentFile = "",
|
||||
audioCurrentPosition = 0L,
|
||||
coverUrl = "",
|
||||
lastSyncedAt = 0L,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.player
|
||||
|
||||
/**
|
||||
* Placeholder for the audio player progress state.
|
||||
*
|
||||
* TODO: When implementing the ExoPlayer/MediaSession audio player,
|
||||
* hook [org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase]
|
||||
* into the playback position update flow, e.g.:
|
||||
*
|
||||
* ```
|
||||
* val book = ...
|
||||
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
|
||||
* val position = player.currentPosition.coerceAtLeast(0L)
|
||||
* val duration = player.duration.coerceAtLeast(book.audioDuration)
|
||||
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
|
||||
* ```
|
||||
*/
|
||||
data class AudioPlayerProgress(
|
||||
val bookId: Int,
|
||||
val currentFile: String,
|
||||
val position: Long,
|
||||
val duration: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.dueattendant149.bookshelf.domain.model.player
|
||||
|
||||
data class AudioTrack(
|
||||
val fileId: String,
|
||||
val title: String,
|
||||
val durationMs: Long,
|
||||
val order: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.reader
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class ReaderPage(
|
||||
val index: Int,
|
||||
val items: List<ReaderText>,
|
||||
val startTextIndex: Int,
|
||||
val endTextIndex: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.rsvp
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Drives RSVP playback for a fixed token stream.
|
||||
*
|
||||
* @param scope scope used for the playback loop (typically `viewModelScope`).
|
||||
*/
|
||||
class RsvpEngine(private val scope: CoroutineScope) {
|
||||
|
||||
data class State(
|
||||
val currentIndex: Int = 0,
|
||||
val isPlaying: Boolean = false,
|
||||
val tokens: List<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.rsvp
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
/**
|
||||
* One word/segment in the RSVP stream.
|
||||
*
|
||||
* @param word the original word as it appears in the book.
|
||||
* @param pivotIndex index of the optimal recognition point within [word];
|
||||
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
|
||||
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
|
||||
* @param isChapterEnd true if the token follows a chapter heading.
|
||||
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
|
||||
*/
|
||||
@Immutable
|
||||
data class RsvpToken(
|
||||
val word: String,
|
||||
val pivotIndex: Int,
|
||||
val isParagraphEnd: Boolean,
|
||||
val isChapterEnd: Boolean,
|
||||
val multiplier: Float = 1f,
|
||||
) {
|
||||
val prefix: String
|
||||
get() = word.substring(0, pivotIndex)
|
||||
|
||||
val pivot: String
|
||||
get() = word.substring(pivotIndex, pivotIndex + 1)
|
||||
|
||||
val suffix: String
|
||||
get() = word.substring(pivotIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
|
||||
* Indices are 0-based positions of the focal character.
|
||||
* Values follow the heuristic table from Spritz/speed-reading literature:
|
||||
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
||||
*/
|
||||
object OrpTable {
|
||||
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
|
||||
|
||||
fun pivotFor(word: String): Int {
|
||||
val len = word.length
|
||||
if (len == 0) return 0
|
||||
if (len <= TABLE.size) return TABLE[len - 1]
|
||||
return (len * 0.3f).toInt().coerceIn(1, len - 1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.rsvp
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
|
||||
/**
|
||||
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
|
||||
* into a flat list of [RsvpToken]s suitable for RSVP playback.
|
||||
*
|
||||
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
|
||||
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
|
||||
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
|
||||
* - Strips punctuation-only tokens and empty whitespace tokens.
|
||||
*/
|
||||
object RsvpTokenizer {
|
||||
|
||||
/**
|
||||
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
|
||||
*/
|
||||
fun tokenizeReaderText(blocks: List<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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.model.tts
|
||||
|
||||
data class TtsEngine(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val supportsStreaming: Boolean,
|
||||
val supportsCloning: Boolean,
|
||||
val maxTextLength: Int,
|
||||
val needsNetwork: Boolean,
|
||||
)
|
||||
|
||||
data class TtsVoice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val language: String,
|
||||
val engine: String,
|
||||
val gender: String,
|
||||
val quality: String,
|
||||
val requiresReference: Boolean,
|
||||
)
|
||||
|
||||
data class TtsJob(
|
||||
val jobId: String,
|
||||
val bookId: String,
|
||||
val title: String,
|
||||
val author: String,
|
||||
val engine: String,
|
||||
val voiceId: String,
|
||||
val speed: Double,
|
||||
val status: String,
|
||||
val progress: Double,
|
||||
val currentChapter: String,
|
||||
val totalChapters: Int,
|
||||
val completedChapters: Int,
|
||||
val outputPath: String,
|
||||
val error: String,
|
||||
val createdAt: Double,
|
||||
val startedAt: Double,
|
||||
val completedAt: Double,
|
||||
) {
|
||||
val isCompleted: Boolean get() = status == "completed"
|
||||
val isFailed: Boolean get() = status == "failed" || status == "error"
|
||||
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.dueattendant149.bookshelf.domain.repository
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
||||
|
||||
interface AudiobookshelfRepository {
|
||||
suspend fun getAudioTracks(itemId: String): Result<List<AudioTrack>>
|
||||
}
|
||||
|
|
@ -43,4 +43,6 @@ interface BookRepository {
|
|||
suspend fun getDefaultCover(
|
||||
book: Book
|
||||
): Result<CoverImage?>
|
||||
|
||||
suspend fun getBooksWithRemoteId(): Result<List<Book>>
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.repository
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
|
||||
interface ProgressRepository {
|
||||
suspend fun syncReadingProgress(
|
||||
book: Book,
|
||||
lastChapter: String? = null,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun syncPlaybackProgress(
|
||||
book: Book,
|
||||
currentFile: String,
|
||||
position: Long,
|
||||
duration: Long,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun getBooksWithRemoteId(): Result<List<Book>>
|
||||
}
|
||||
|
|
@ -6,6 +6,11 @@
|
|||
|
||||
package org.dueattendant149.bookshelf.domain.repository
|
||||
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||
|
||||
|
|
@ -13,4 +18,8 @@ interface RemoteLibraryRepository {
|
|||
suspend fun getLibraries(): Result<List<RemoteLibrary>>
|
||||
suspend fun getBooks(libraryId: 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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.repository
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
||||
|
||||
interface RemoteTtsRepository {
|
||||
suspend fun fetchEngines(): Result<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>
|
||||
}
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.reader
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextMeasurer
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||
import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PaginateReaderTextUseCase @Inject constructor() {
|
||||
|
||||
operator fun invoke(
|
||||
text: List<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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class GlobalSearchUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteLibraryRepository,
|
||||
) {
|
||||
suspend operator fun invoke(request: SearchRequest): Result<SearchResponse> =
|
||||
repository.search(request)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class ListDownloadsUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteLibraryRepository,
|
||||
) {
|
||||
suspend operator fun invoke(): Result<DownloadsListResponse> =
|
||||
repository.listDownloads()
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class StartDownloadUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteLibraryRepository,
|
||||
) {
|
||||
suspend operator fun invoke(request: DownloadRequest): Result<DownloadResponse> =
|
||||
repository.startDownload(request)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class SyncPlaybackProgressUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val progressRepository: ProgressRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
book: Book,
|
||||
currentFile: String,
|
||||
position: Long,
|
||||
duration: Long,
|
||||
): Result<Unit> {
|
||||
if (book.remoteId.isBlank()) {
|
||||
return Result.success(Unit)
|
||||
}
|
||||
return progressRepository.syncPlaybackProgress(
|
||||
book = book,
|
||||
currentFile = currentFile,
|
||||
position = position,
|
||||
duration = duration,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class SyncReadingProgressUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val progressRepository: ProgressRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
book: Book,
|
||||
lastChapter: String? = null,
|
||||
): Result<Unit> {
|
||||
if (book.remoteId.isBlank()) {
|
||||
return Result.success(Unit)
|
||||
}
|
||||
return progressRepository.syncReadingProgress(book, lastChapter)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class UploadBookUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteLibraryRepository,
|
||||
) {
|
||||
suspend operator fun invoke(file: File, bookType: String): Result<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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class CreateTtsJobUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteTtsRepository,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
bookId: String,
|
||||
engine: String,
|
||||
voiceId: String,
|
||||
speed: Double,
|
||||
): Result<TtsJob> = repository.createJob(bookId, engine, voiceId, speed)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class FetchTtsEnginesUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteTtsRepository,
|
||||
) {
|
||||
suspend operator fun invoke(): Result<List<TtsEngine>> = repository.fetchEngines()
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class FetchTtsVoicesUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteTtsRepository,
|
||||
) {
|
||||
suspend operator fun invoke(engine: String? = null): Result<List<TtsVoice>> =
|
||||
repository.fetchVoices(engine)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
class GetTtsJobUseCase
|
||||
@Inject
|
||||
constructor(
|
||||
private val repository: RemoteTtsRepository,
|
||||
) {
|
||||
suspend operator fun invoke(jobId: String): Result<TtsJob> = repository.getJob(jobId)
|
||||
}
|
||||
|
|
@ -20,14 +20,19 @@ fun String.isValidUri(): Boolean {
|
|||
/**
|
||||
* Returns true if the string starts with http:// or https:// (case-insensitive).
|
||||
*/
|
||||
fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX)
|
||||
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
|
||||
|
||||
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
|
||||
|
||||
/**
|
||||
* Adds https:// if the string has no http/https scheme.
|
||||
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
|
||||
*/
|
||||
fun String.ensureUriScheme(): String {
|
||||
val trimmed = trim()
|
||||
return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
|
||||
if (trimmed.hasUriScheme()) return trimmed
|
||||
val hostPart = trimmed.substringBefore("/")
|
||||
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
|
||||
return "$scheme$trimmed"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AudiobooksModel
|
||||
@Inject
|
||||
constructor(
|
||||
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||
fetchBooksUseCase: FetchBooksUseCase,
|
||||
cacheBookUseCase: CacheBookUseCase,
|
||||
deleteCacheUseCase: DeleteCacheUseCase,
|
||||
getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||
serverSettings: ServerSettings,
|
||||
serverStatusMonitor: ServerStatusMonitor,
|
||||
) : RemoteLibraryViewModel(
|
||||
fetchLibrariesUseCase,
|
||||
fetchBooksUseCase,
|
||||
cacheBookUseCase,
|
||||
deleteCacheUseCase,
|
||||
getCacheStatusUseCase,
|
||||
serverSettings,
|
||||
serverStatusMonitor,
|
||||
) {
|
||||
override val libraryNameFilter: String? = "audiobook"
|
||||
override val itemTypeFilter: String? = "audiobook"
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
||||
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
|
||||
@Parcelize
|
||||
object AudiobooksScreen : Screen, Parcelable {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<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.
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class BooksModel
|
||||
@Inject
|
||||
constructor(
|
||||
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||
fetchBooksUseCase: FetchBooksUseCase,
|
||||
cacheBookUseCase: CacheBookUseCase,
|
||||
deleteCacheUseCase: DeleteCacheUseCase,
|
||||
getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||
serverSettings: ServerSettings,
|
||||
serverStatusMonitor: ServerStatusMonitor,
|
||||
) : RemoteLibraryViewModel(
|
||||
fetchLibrariesUseCase,
|
||||
fetchBooksUseCase,
|
||||
cacheBookUseCase,
|
||||
deleteCacheUseCase,
|
||||
getCacheStatusUseCase,
|
||||
serverSettings,
|
||||
serverStatusMonitor,
|
||||
) {
|
||||
override val libraryNameFilter: String? = "book"
|
||||
override val itemTypeFilter: String? = "book"
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
||||
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
|
||||
@Parcelize
|
||||
object BooksScreen : Screen, Parcelable {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -6,23 +6,9 @@
|
|||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||
|
|
@ -30,145 +16,26 @@ import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
|||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
data class RemoteLibraryState(
|
||||
val libraries: List<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
|
||||
class RemoteLibraryModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||
private val fetchBooksUseCase: FetchBooksUseCase,
|
||||
private val cacheBookUseCase: CacheBookUseCase,
|
||||
private val deleteCacheUseCase: DeleteCacheUseCase,
|
||||
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||
private val serverSettings: ServerSettings,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(RemoteLibraryState())
|
||||
val state = _state.asStateFlow()
|
||||
private var cacheStatusJob: Job? = null
|
||||
|
||||
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadLibraries()
|
||||
}
|
||||
|
||||
fun loadLibraries() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
if (!serverSettings.hasCredentials()) {
|
||||
_state.update { it.copy(isLoading = false, error = "Server not configured") }
|
||||
return@launch
|
||||
}
|
||||
val result = fetchLibrariesUseCase()
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
libraries = result.getOrDefault(emptyList()),
|
||||
error = result.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectLibrary(library: RemoteLibrary) {
|
||||
_state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
|
||||
loadBooks(library.id)
|
||||
}
|
||||
|
||||
fun loadBooks(libraryId: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
val result = fetchBooksUseCase(libraryId)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
books = result.getOrDefault(emptyList()),
|
||||
error = result.exceptionOrNull()?.message,
|
||||
)
|
||||
}
|
||||
observeCacheStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
fun cacheBook(book: Book) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||
val result = cacheBookUseCase(book)
|
||||
_state.update { it.copy(cachingBookId = null) }
|
||||
if (result.isFailure) {
|
||||
_state.update {
|
||||
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
||||
}
|
||||
}
|
||||
observeCacheStatuses()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCache(book: Book) {
|
||||
viewModelScope.launch {
|
||||
val result = deleteCacheUseCase(book.remoteId)
|
||||
if (result.isFailure) {
|
||||
_state.update {
|
||||
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleOfflineOnly() {
|
||||
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
|
||||
}
|
||||
|
||||
fun openBook(book: Book) {
|
||||
if (!book.hasEbook || book.remoteId.isBlank()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
cacheBookUseCase(book)
|
||||
}
|
||||
_state.update { it.copy(cachingBookId = null) }
|
||||
|
||||
result
|
||||
.onSuccess { bookId ->
|
||||
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.update { it.copy(error = error.message ?: "Could not open book") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeCacheStatuses() {
|
||||
cacheStatusJob?.cancel()
|
||||
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
||||
if (remoteIds.isEmpty()) {
|
||||
_state.update { it.copy(cacheStatuses = emptyMap()) }
|
||||
return
|
||||
}
|
||||
|
||||
cacheStatusJob =
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
remoteIds.map { remoteId ->
|
||||
getCacheStatusUseCase(remoteId)
|
||||
},
|
||||
) { statuses ->
|
||||
statuses.toList().associateBy { it.remoteId }
|
||||
}.collect { statuses ->
|
||||
_state.update { it.copy(cacheStatuses = statuses) }
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||
fetchBooksUseCase: FetchBooksUseCase,
|
||||
cacheBookUseCase: CacheBookUseCase,
|
||||
deleteCacheUseCase: DeleteCacheUseCase,
|
||||
getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||
serverSettings: ServerSettings,
|
||||
serverStatusMonitor: ServerStatusMonitor,
|
||||
) : RemoteLibraryViewModel(
|
||||
fetchLibrariesUseCase,
|
||||
fetchBooksUseCase,
|
||||
cacheBookUseCase,
|
||||
deleteCacheUseCase,
|
||||
getCacheStatusUseCase,
|
||||
serverSettings,
|
||||
serverStatusMonitor,
|
||||
) {
|
||||
override val libraryNameFilter: String? = null
|
||||
override val itemTypeFilter: String? = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,12 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
|
||||
|
|
@ -37,6 +40,15 @@ object RemoteLibraryScreen : Screen, Parcelable {
|
|||
}
|
||||
}
|
||||
|
||||
RemoteLibraryContent(model = model)
|
||||
RemoteLibraryContent(
|
||||
model = model,
|
||||
titleRes = R.string.bookshelf_screen,
|
||||
onPlayAudio = { book ->
|
||||
navigator.push(PlayerScreen(book))
|
||||
},
|
||||
navigateToTts = { bookRemoteId ->
|
||||
navigator.push(TtsScreen(bookRemoteId))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||
|
||||
/**
|
||||
* Shared UI state for remote-library tabs.
|
||||
*/
|
||||
data class RemoteLibraryState(
|
||||
val libraries: List<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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,13 +24,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import kotlinx.collections.immutable.persistentListOf
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.data.settings.SettingsManager
|
||||
import org.dueattendant149.bookshelf.presentation.browse.BrowseModel
|
||||
import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryModel
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.library.LibraryModel
|
||||
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.bookshelf.BooksScreen
|
||||
import org.dueattendant149.bookshelf.presentation.bookshelf.AudiobooksScreen
|
||||
import org.dueattendant149.bookshelf.presentation.search.SearchScreen
|
||||
import org.dueattendant149.bookshelf.presentation.settings.SettingsScreen
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.StackEvent
|
||||
import org.dueattendant149.bookshelf.presentation.settings.SettingsModel
|
||||
|
|
@ -77,11 +74,6 @@ class MainActivity : AppCompatActivity() {
|
|||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
// Initializing Screen Models
|
||||
val libraryModel = hiltViewModel<LibraryModel>()
|
||||
val historyModel = hiltViewModel<HistoryModel>()
|
||||
val browseModel = hiltViewModel<BrowseModel>()
|
||||
|
||||
SettingsEffects(
|
||||
effects = settingsModel.effects
|
||||
)
|
||||
|
|
@ -89,32 +81,32 @@ class MainActivity : AppCompatActivity() {
|
|||
ProvideSettings(settings) {
|
||||
val tabs = persistentListOf(
|
||||
NavigatorItem(
|
||||
screen = LibraryScreen,
|
||||
title = R.string.library_screen,
|
||||
tooltip = R.string.library_content_desc,
|
||||
selectedIcon = R.drawable.library_screen_filled,
|
||||
unselectedIcon = R.drawable.library_screen_outlined
|
||||
screen = BooksScreen,
|
||||
title = R.string.books_screen,
|
||||
tooltip = R.string.books_content_desc,
|
||||
selectedIcon = R.drawable.book_tab_filled,
|
||||
unselectedIcon = R.drawable.book_tab_outlined
|
||||
),
|
||||
NavigatorItem(
|
||||
screen = HistoryScreen,
|
||||
title = R.string.history_screen,
|
||||
tooltip = R.string.history_content_desc,
|
||||
selectedIcon = R.drawable.history_screen_filled,
|
||||
unselectedIcon = R.drawable.history_screen_outlined
|
||||
screen = AudiobooksScreen,
|
||||
title = R.string.audiobooks_screen,
|
||||
tooltip = R.string.audiobooks_content_desc,
|
||||
selectedIcon = R.drawable.audiobook_tab_filled,
|
||||
unselectedIcon = R.drawable.audiobook_tab_outlined
|
||||
),
|
||||
NavigatorItem(
|
||||
screen = BrowseScreen,
|
||||
title = R.string.browse_screen,
|
||||
tooltip = R.string.browse_content_desc,
|
||||
selectedIcon = R.drawable.browse_screen_filled,
|
||||
unselectedIcon = R.drawable.browse_screen_outlined
|
||||
screen = SearchScreen,
|
||||
title = R.string.search_screen,
|
||||
tooltip = R.string.search_content_desc_network,
|
||||
selectedIcon = R.drawable.search_tab_filled,
|
||||
unselectedIcon = R.drawable.search_tab_outlined
|
||||
),
|
||||
NavigatorItem(
|
||||
screen = RemoteLibraryScreen,
|
||||
title = R.string.bookshelf_screen,
|
||||
tooltip = R.string.bookshelf_content_desc,
|
||||
selectedIcon = R.drawable.bookshelf_screen_filled,
|
||||
unselectedIcon = R.drawable.bookshelf_screen_outlined
|
||||
screen = SettingsScreen,
|
||||
title = R.string.settings_screen,
|
||||
tooltip = R.string.settings_screen,
|
||||
selectedIcon = R.drawable.settings_tab_filled,
|
||||
unselectedIcon = R.drawable.settings_tab_outlined
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -129,7 +121,7 @@ class MainActivity : AppCompatActivity() {
|
|||
) {
|
||||
Navigator(
|
||||
initialScreen = if (settings.showStartScreen.value) StartScreen
|
||||
else LibraryScreen,
|
||||
else BooksScreen,
|
||||
transitionSpec = { lastEvent ->
|
||||
when (lastEvent) {
|
||||
StackEvent.DEFAULT -> {
|
||||
|
|
@ -145,14 +137,14 @@ class MainActivity : AppCompatActivity() {
|
|||
},
|
||||
contentKey = {
|
||||
when (it) {
|
||||
LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs"
|
||||
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs"
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
backHandlerEnabled = { it != StartScreen }
|
||||
) { screen ->
|
||||
) { screen ->
|
||||
when (screen) {
|
||||
LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> {
|
||||
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> {
|
||||
NavigatorTabs(
|
||||
currentTab = screen,
|
||||
transitionSpec = {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,8 @@ class Navigator @AssistedInject constructor(
|
|||
popping: Boolean = false,
|
||||
saveInBackStack: Boolean = true
|
||||
) {
|
||||
if (lastItem.value::class == targetScreen::class) return
|
||||
val current = lastItem.value
|
||||
if (current::class == targetScreen::class && current == targetScreen) return
|
||||
if (!saveInBackStack) items.removeLast()
|
||||
|
||||
changeStackEvent(
|
||||
|
|
@ -60,7 +61,6 @@ class Navigator @AssistedInject constructor(
|
|||
else StackEvent.DEFAULT
|
||||
)
|
||||
|
||||
if (lastItem.value::class == targetScreen::class) items.removeLast()
|
||||
items.add(targetScreen)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
package org.dueattendant149.bookshelf.presentation.player
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
|
||||
@Parcelize
|
||||
data class ChapterListScreen(val book: Book) : Screen, Parcelable {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,349 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.player
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.cache.CacheDataSource
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.LoadControl
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||
import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
|
||||
import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
|
||||
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
|
||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
|
||||
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PlayerModel
|
||||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val serverSettings: ServerSettings,
|
||||
private val audiobookshelfRepository: AudiobookshelfRepository,
|
||||
private val database: BookDatabase,
|
||||
private val bookMapper: BookMapper,
|
||||
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
|
||||
private val cacheBookUseCase: CacheBookUseCase,
|
||||
private val okHttpClient: okhttp3.OkHttpClient,
|
||||
private val authProvider: PlaybackAuthProvider,
|
||||
private val simpleCache: SimpleCache,
|
||||
) : ViewModel() {
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
val exoPlayer: ExoPlayer by lazy {
|
||||
val authClient = okHttpClient.newBuilder()
|
||||
.addInterceptor(AuthorizationInterceptor(authProvider::token))
|
||||
.build()
|
||||
val upstreamFactory = OkHttpDataSource.Factory(authClient)
|
||||
val cacheDataSourceFactory = CacheDataSource.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(upstreamFactory)
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
ExoPlayer.Builder(context)
|
||||
.setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true))
|
||||
.setTrackSelector(DefaultTrackSelector(context))
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
|
||||
.setLoadControl(createLoadControl())
|
||||
.build()
|
||||
.also { it.addListener(listener) }
|
||||
}
|
||||
|
||||
private fun createLoadControl(): LoadControl {
|
||||
return androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
||||
.setAllocator(DefaultAllocator(true, 16 * 1024))
|
||||
.setBufferDurationsMs(
|
||||
/* minBufferMs = */ 30_000,
|
||||
/* maxBufferMs = */ 120_000,
|
||||
/* bufferForPlaybackMs = */ 2_500,
|
||||
/* bufferForPlaybackAfterRebufferMs = */ 5_000,
|
||||
)
|
||||
.setPrioritizeTimeOverSizeThresholds(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(PlayerState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val listener =
|
||||
object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
_state.update { it.copy(isPlaying = isPlaying) }
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = playbackState == Player.STATE_BUFFERING,
|
||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int,
|
||||
) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentTrackIndex = exoPlayer.currentMediaItemIndex,
|
||||
currentPositionMs = newPosition.positionMs.coerceAtLeast(0L),
|
||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentTrackIndex = exoPlayer.currentMediaItemIndex,
|
||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
||||
currentPositionMs = 0L,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
_state.update { it.copy(error = error.localizedMessage, isLoading = false) }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
startProgressUpdates()
|
||||
}
|
||||
|
||||
fun init(book: Book, startTrackIndex: Int = 0) {
|
||||
if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank() && startTrackIndex == 0) return
|
||||
_state.update { it.copy(book = book, isLoading = true, error = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
authProvider.refresh(serverSettings)
|
||||
|
||||
val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme()
|
||||
if (baseUrl.isNullOrBlank()) {
|
||||
_state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
||||
tracksResult.fold(
|
||||
onSuccess = { tracks ->
|
||||
if (tracks.isEmpty()) {
|
||||
_state.update { it.copy(isLoading = false, error = "No audio files found") }
|
||||
return@fold
|
||||
}
|
||||
_state.update { it.copy(tracks = tracks) }
|
||||
val startIndex = startTrackIndex.coerceIn(0, tracks.lastIndex)
|
||||
val startPosition = if (startIndex == 0) book.audioCurrentPosition else 0L
|
||||
loadTracks(baseUrl, tracks, startIndex, startPosition)
|
||||
startService(book)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_state.update { it.copy(isLoading = false, error = error.message) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadTracks(baseUrl: String, tracks: List<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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.player
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.ui.player.PlayerContent
|
||||
|
||||
@Parcelize
|
||||
data class PlayerScreen(
|
||||
val book: Book,
|
||||
val startTrackIndex: Int = 0,
|
||||
) : Screen, Parcelable {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.dueattendant149.bookshelf.presentation.player
|
||||
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
||||
|
||||
data class PlayerState(
|
||||
val book: Book = Book.default,
|
||||
val tracks: List<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,
|
||||
)
|
||||
|
|
@ -38,4 +38,8 @@ sealed class ReaderEffect {
|
|||
data class OnNavigateToBookInfo(
|
||||
val changePath: Boolean
|
||||
) : ReaderEffect()
|
||||
|
||||
data class OnNavigateToRsvp(
|
||||
val bookId: Int
|
||||
) : ReaderEffect()
|
||||
}
|
||||
|
|
@ -40,6 +40,14 @@ sealed class ReaderEvent {
|
|||
val progress: Float
|
||||
) : 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(
|
||||
val checkpoint: Checkpoint
|
||||
) : ReaderEvent()
|
||||
|
|
@ -78,4 +86,8 @@ sealed class ReaderEvent {
|
|||
data class OnNavigateToBookInfo(
|
||||
val changePath: Boolean
|
||||
) : ReaderEvent()
|
||||
|
||||
data class OnNavigateToRsvp(
|
||||
val bookId: Int
|
||||
) : ReaderEvent()
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseC
|
|||
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||
|
|
@ -50,7 +52,9 @@ class ReaderModel @Inject constructor(
|
|||
private val getTextUseCase: GetTextUseCase,
|
||||
private val getBookUseCase: GetBookUseCase,
|
||||
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
||||
private val getChapterProgressUseCase: GetChapterProgressUseCase
|
||||
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
||||
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
||||
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
|
||||
) : ViewModel() {
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
|
@ -94,7 +98,9 @@ class ReaderModel @Inject constructor(
|
|||
book = it.book.copy(
|
||||
lastOpened = lastOpened
|
||||
),
|
||||
text = text
|
||||
text = text,
|
||||
pages = emptyList(),
|
||||
currentPage = 0
|
||||
)
|
||||
}
|
||||
ensureActive()
|
||||
|
|
@ -109,24 +115,34 @@ class ReaderModel @Inject constructor(
|
|||
}
|
||||
|
||||
is ReaderEvent.OnRestoreScroll -> {
|
||||
snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 }
|
||||
val state = _state.value
|
||||
if (state.pages.isNotEmpty()) {
|
||||
val page = state.pages
|
||||
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
onEvent(ReaderEvent.OnChangePage(page))
|
||||
_state.update { it.copy(isLoading = false, errorMessage = null) }
|
||||
} else {
|
||||
snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 }
|
||||
|
||||
_state.value.listState.requestScrollToItem(
|
||||
index = _state.value.book.scrollIndex,
|
||||
scrollOffset = _state.value.book.scrollOffset
|
||||
)
|
||||
state.listState.requestScrollToItem(
|
||||
index = state.book.scrollIndex,
|
||||
scrollOffset = state.book.scrollOffset
|
||||
)
|
||||
|
||||
_state.update {
|
||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||
it.book.scrollIndex,
|
||||
it.text
|
||||
)
|
||||
it.copy(
|
||||
currentChapter = currentChapter,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
)
|
||||
_state.update {
|
||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||
it.book.scrollIndex,
|
||||
it.text
|
||||
)
|
||||
it.copy(
|
||||
currentChapter = currentChapter,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
isLoading = false,
|
||||
errorMessage = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -177,6 +193,10 @@ class ReaderModel @Inject constructor(
|
|||
}
|
||||
|
||||
updateBookUseCase(_state.value.book)
|
||||
syncReadingProgressUseCase(
|
||||
_state.value.book,
|
||||
_state.value.currentChapter?.title,
|
||||
)
|
||||
|
||||
LibraryScreen.refreshListChannel.trySend(300)
|
||||
HistoryScreen.refreshListChannel.trySend(300)
|
||||
|
|
@ -224,14 +244,69 @@ class ReaderModel @Inject constructor(
|
|||
scrollJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(300)
|
||||
|
||||
val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
|
||||
val state = _state.value
|
||||
if (state.pages.isNotEmpty()) {
|
||||
val targetPage = (event.progress * state.pages.lastIndex).roundToInt()
|
||||
onEvent(ReaderEvent.OnChangePage(targetPage))
|
||||
} else {
|
||||
val scrollTo = (state.text.lastIndex * event.progress).roundToInt()
|
||||
state.listState.requestScrollToItem(
|
||||
index = scrollTo,
|
||||
scrollOffset = 0
|
||||
)
|
||||
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state.value.listState.requestScrollToItem(
|
||||
index = scrollTo,
|
||||
scrollOffset = 0
|
||||
is ReaderEvent.OnChangePage -> {
|
||||
withContext(Dispatchers.Default) {
|
||||
val state = _state.value
|
||||
val pages = state.pages
|
||||
if (pages.isEmpty()) return@withContext
|
||||
val page = event.page.coerceIn(0, pages.lastIndex)
|
||||
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
|
||||
|
||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||
pageStart,
|
||||
state.text
|
||||
)
|
||||
|
||||
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
|
||||
_state.update {
|
||||
it.copy(
|
||||
currentPage = page,
|
||||
currentChapter = currentChapter,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
book = it.book.copy(
|
||||
progress = calculateProgress(page),
|
||||
scrollIndex = pageStart,
|
||||
scrollOffset = 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
updateBookUseCase(_state.value.book)
|
||||
syncReadingProgressUseCase(
|
||||
_state.value.book,
|
||||
_state.value.currentChapter?.title,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is ReaderEvent.OnPagesComputed -> {
|
||||
withContext(Dispatchers.Default) {
|
||||
val pages = event.pages
|
||||
if (pages.isEmpty()) {
|
||||
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
|
||||
return@withContext
|
||||
}
|
||||
// Restore the page that contains the saved scrollIndex.
|
||||
val savedIndex = _state.value.book.scrollIndex
|
||||
val targetPage = pages
|
||||
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
|
||||
.takeIf { it >= 0 }
|
||||
?: 0
|
||||
_state.update { it.copy(pages = pages, currentPage = targetPage) }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +361,10 @@ class ReaderModel @Inject constructor(
|
|||
}
|
||||
|
||||
updateBookUseCase(_state.value.book)
|
||||
syncReadingProgressUseCase(
|
||||
_state.value.book,
|
||||
_state.value.currentChapter?.title,
|
||||
)
|
||||
|
||||
LibraryScreen.refreshListChannel.trySend(0)
|
||||
HistoryScreen.refreshListChannel.trySend(0)
|
||||
|
|
@ -374,6 +453,10 @@ class ReaderModel @Inject constructor(
|
|||
is ReaderEvent.OnNavigateToBookInfo -> {
|
||||
_effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath))
|
||||
}
|
||||
|
||||
is ReaderEvent.OnNavigateToRsvp -> {
|
||||
_effects.emit(ReaderEffect.OnNavigateToRsvp(event.bookId))
|
||||
}
|
||||
}
|
||||
}.also { eventStack.add(it) }
|
||||
}
|
||||
|
|
@ -448,6 +531,10 @@ class ReaderModel @Inject constructor(
|
|||
}
|
||||
|
||||
updateBookUseCase(_state.value.book)
|
||||
syncReadingProgressUseCase(
|
||||
_state.value.book,
|
||||
_state.value.currentChapter?.title,
|
||||
)
|
||||
|
||||
LibraryScreen.refreshListChannel.trySend(0)
|
||||
HistoryScreen.refreshListChannel.trySend(0)
|
||||
|
|
@ -470,22 +557,31 @@ class ReaderModel @Inject constructor(
|
|||
} ?: (-1 to -1)
|
||||
}
|
||||
|
||||
private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
|
||||
private fun calculateProgress(pageOrIndex: Int? = null): Float {
|
||||
val state = _state.value
|
||||
if (
|
||||
_state.value.isLoading ||
|
||||
_state.value.listState.layoutInfo.totalItemsCount == 0 ||
|
||||
_state.value.text.isEmpty() ||
|
||||
_state.value.errorMessage != null
|
||||
) return _state.value.book.progress
|
||||
state.isLoading ||
|
||||
state.text.isEmpty() ||
|
||||
state.errorMessage != null
|
||||
) return state.book.progress
|
||||
|
||||
if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f
|
||||
if (state.pages.isNotEmpty()) {
|
||||
if (state.pages.size <= 1) return 0f
|
||||
val page = pageOrIndex ?: state.currentPage
|
||||
return page.coerceIn(0, state.pages.lastIndex)
|
||||
.div(state.pages.lastIndex.toFloat())
|
||||
.coerceAndPreventNaN()
|
||||
}
|
||||
|
||||
val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index
|
||||
if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f
|
||||
if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress
|
||||
|
||||
return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex)
|
||||
.div(_state.value.text.lastIndex.toFloat())
|
||||
.coerceAndPreventNaN()
|
||||
val index = pageOrIndex ?: state.listState.firstVisibleItemIndex
|
||||
if (index == 0) return 0f
|
||||
|
||||
val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
|
||||
if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
|
||||
|
||||
return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
|
||||
}
|
||||
|
||||
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.presentation.reader
|
|||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.os.Parcelable
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
|
|
@ -23,19 +24,29 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.BlendMode
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.em
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -55,6 +66,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
|||
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
||||
import org.dueattendant149.bookshelf.ui.reader.ReaderContent
|
||||
import org.dueattendant149.bookshelf.ui.reader.ReaderEffects
|
||||
import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Parcelize
|
||||
|
|
@ -83,6 +95,11 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
) {
|
||||
state.value.listState
|
||||
}
|
||||
|
||||
val configuration = LocalConfiguration.current
|
||||
val textMeasurer = rememberTextMeasurer()
|
||||
|
||||
val readerPagination = settings.readerPagination.value
|
||||
val nestedScrollConnection = remember {
|
||||
derivedStateOf {
|
||||
object : NestedScrollConnection {
|
||||
|
|
@ -155,76 +172,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
) return@remember 0.sp
|
||||
(settings.paragraphIndentation.lastValue * 6).sp
|
||||
}
|
||||
val perceptionExpanderPadding = remember(
|
||||
sidePadding,
|
||||
settings.perceptionExpanderPadding.value
|
||||
) {
|
||||
sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
|
||||
}
|
||||
val perceptionExpanderThickness = remember(
|
||||
settings.perceptionExpanderThickness.value
|
||||
) {
|
||||
(settings.perceptionExpanderThickness.lastValue * 0.25f).dp
|
||||
}
|
||||
val horizontalLimiterHeight = remember(
|
||||
settings.horizontalLimiterHeight.value
|
||||
) {
|
||||
(settings.horizontalLimiterHeight.lastValue * 20).dp
|
||||
}
|
||||
val horizontalLimiterRulerThickness = remember(
|
||||
settings.horizontalLimiterRulerThickness.value
|
||||
) {
|
||||
(settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
|
||||
}
|
||||
val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
|
||||
(36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
|
||||
}
|
||||
val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
|
||||
when (settings.highlightedReadingThickness.lastValue) {
|
||||
2 -> FontWeight.SemiBold
|
||||
3 -> FontWeight.Bold
|
||||
else -> FontWeight.Medium
|
||||
}
|
||||
}
|
||||
val horizontalAlignment = remember(settings.textAlignment.value) {
|
||||
when (settings.textAlignment.lastValue) {
|
||||
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
|
||||
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
|
||||
ReaderTextAlignment.END -> Alignment.End
|
||||
}
|
||||
}
|
||||
val imagesWidth = remember(settings.imagesWidth.value) {
|
||||
settings.imagesWidth.lastValue.coerceAtLeast(0.01f)
|
||||
}
|
||||
val imagesCornersRoundness = remember(
|
||||
settings.imagesCornersRoundness.value,
|
||||
settings.imagesWidth.value
|
||||
) {
|
||||
(settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
|
||||
}
|
||||
val imagesColorEffects = remember(
|
||||
settings.imagesColorEffects.value,
|
||||
fontColor.value,
|
||||
backgroundColor.value
|
||||
) {
|
||||
when (settings.imagesColorEffects.lastValue) {
|
||||
ReaderColorEffects.OFF -> null
|
||||
|
||||
ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
|
||||
ColorMatrix().apply { setToSaturation(0f) }
|
||||
)
|
||||
|
||||
ReaderColorEffects.FONT -> ColorFilter.tint(
|
||||
color = fontColor.value,
|
||||
blendMode = BlendMode.Color
|
||||
)
|
||||
|
||||
ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
|
||||
color = backgroundColor.value,
|
||||
blendMode = BlendMode.Color
|
||||
)
|
||||
}
|
||||
}
|
||||
val progressBarPadding = remember(settings.progressBarPadding.value) {
|
||||
(settings.progressBarPadding.lastValue * 3).dp
|
||||
}
|
||||
|
|
@ -287,6 +237,158 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Recompute pages when text or relevant settings change.
|
||||
val pages by produceState(
|
||||
initialValue = emptyList<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) {
|
||||
(settings.bottomBarPadding.lastValue * 4f).dp
|
||||
}
|
||||
|
|
@ -336,6 +438,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
LaunchedEffect(Unit) {
|
||||
screenModel.init(bookId = bookId)
|
||||
}
|
||||
LaunchedEffect(pages) {
|
||||
screenModel.onEvent(ReaderEvent.OnPagesComputed(pages))
|
||||
}
|
||||
LaunchedEffect(settings.fullscreen.value) {
|
||||
screenModel.onEvent(
|
||||
ReaderEvent.OnMenuVisibility(
|
||||
|
|
@ -372,11 +477,45 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
onDispose {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(readerPagination, state.value.pages, state.value.currentPage) {
|
||||
if (!readerPagination || state.value.pages.isEmpty()) {
|
||||
onDispose { }
|
||||
} else {
|
||||
val pages = state.value.pages
|
||||
val currentRef = java.util.concurrent.atomic.AtomicInteger(state.value.currentPage)
|
||||
val originalCallback = activity.window.callback
|
||||
val proxy = org.dueattendant149.bookshelf.ui.reader.VolumeKeyWindowCallback(
|
||||
delegate = originalCallback,
|
||||
onKeyEvent = { event ->
|
||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
||||
val current = currentRef.get()
|
||||
val target = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_UP -> if (current > 0) current - 1 else -1
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN -> if (current < pages.lastIndex) current + 1 else -1
|
||||
else -> -1
|
||||
}
|
||||
if (target >= 0) {
|
||||
currentRef.set(target)
|
||||
screenModel.onEvent(ReaderEvent.OnChangePage(target))
|
||||
return@VolumeKeyWindowCallback true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
)
|
||||
activity.window.callback = proxy
|
||||
|
||||
onDispose {
|
||||
activity.window.callback = originalCallback
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
screenModel.clearAsync()
|
||||
|
|
@ -393,32 +532,44 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
fullscreen = settings.fullscreen.value
|
||||
)
|
||||
|
||||
ReaderContent(
|
||||
book = state.value.book,
|
||||
text = state.value.text,
|
||||
bottomSheet = state.value.bottomSheet,
|
||||
drawer = state.value.drawer,
|
||||
listState = listState,
|
||||
currentChapter = state.value.currentChapter,
|
||||
nestedScrollConnection = nestedScrollConnection.value,
|
||||
fastColorPresetChange = settings.fastColorPresetChange.value,
|
||||
perceptionExpander = settings.perceptionExpander.value,
|
||||
perceptionExpanderPadding = perceptionExpanderPadding,
|
||||
perceptionExpanderThickness = perceptionExpanderThickness,
|
||||
horizontalLimiter = settings.horizontalLimiter.value,
|
||||
horizontalLimiterHeight = horizontalLimiterHeight,
|
||||
horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
|
||||
horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
|
||||
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
||||
horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
|
||||
currentChapterProgress = state.value.currentChapterProgress,
|
||||
isLoading = state.value.isLoading,
|
||||
errorMessage = state.value.errorMessage,
|
||||
checkpoints = state.value.checkpoints,
|
||||
showMenu = state.value.showMenu,
|
||||
lockMenu = state.value.lockMenu,
|
||||
contentPadding = contentPadding,
|
||||
verticalPadding = verticalPadding,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.focusable()
|
||||
) {
|
||||
ReaderContent(
|
||||
book = state.value.book,
|
||||
text = state.value.text,
|
||||
bottomSheet = state.value.bottomSheet,
|
||||
drawer = state.value.drawer,
|
||||
listState = listState,
|
||||
currentChapter = state.value.currentChapter,
|
||||
nestedScrollConnection = nestedScrollConnection.value,
|
||||
fastColorPresetChange = settings.fastColorPresetChange.value,
|
||||
perceptionExpander = settings.perceptionExpander.value,
|
||||
perceptionExpanderPadding = perceptionExpanderPadding,
|
||||
perceptionExpanderThickness = perceptionExpanderThickness,
|
||||
horizontalLimiter = settings.horizontalLimiter.value,
|
||||
horizontalLimiterHeight = horizontalLimiterHeight,
|
||||
horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
|
||||
horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
|
||||
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
||||
horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
|
||||
currentChapterProgress = state.value.currentChapterProgress,
|
||||
isLoading = state.value.isLoading,
|
||||
errorMessage = state.value.errorMessage,
|
||||
checkpoints = state.value.checkpoints,
|
||||
showMenu = state.value.showMenu,
|
||||
lockMenu = state.value.lockMenu,
|
||||
readerPagination = readerPagination,
|
||||
pages = state.value.pages,
|
||||
currentPage = state.value.currentPage,
|
||||
onPageChanged = { page ->
|
||||
if (state.value.pages.isNotEmpty()) {
|
||||
screenModel.onEvent(ReaderEvent.OnChangePage(page))
|
||||
}
|
||||
},
|
||||
contentPadding = contentPadding,
|
||||
verticalPadding = verticalPadding,
|
||||
horizontalGesture = settings.horizontalGesture.value,
|
||||
horizontalGestureScroll = settings.horizontalGestureScroll.value,
|
||||
horizontalGestureSensitivity = horizontalGestureSensitivity,
|
||||
|
|
@ -470,7 +621,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
|||
showChaptersDrawer = screenModel::onEvent,
|
||||
dismissDrawer = screenModel::onEvent,
|
||||
navigateBack = screenModel::onEvent,
|
||||
navigateToBookInfo = screenModel::onEvent
|
||||
navigateToBookInfo = screenModel::onEvent,
|
||||
navigateToRsvp = screenModel::onEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import org.dueattendant149.bookshelf.core.BottomSheet
|
|||
import org.dueattendant149.bookshelf.core.Drawer
|
||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||
|
|
@ -20,6 +21,8 @@ import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
|||
data class ReaderState(
|
||||
val book: Book = Book.default,
|
||||
val text: List<ReaderText> = emptyList(),
|
||||
val pages: List<ReaderPage> = emptyList(),
|
||||
val currentPage: Int = 0,
|
||||
val listState: LazyListState = LazyListState(),
|
||||
|
||||
val currentChapter: Chapter? = null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||
|
||||
sealed class RsvpEffect {
|
||||
data object OnNavigateBack : RsvpEffect()
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||
|
||||
sealed class RsvpEvent {
|
||||
data object OnPlayPause : RsvpEvent()
|
||||
data object OnStop : RsvpEvent()
|
||||
data object OnClose : RsvpEvent()
|
||||
data class OnWpmChange(val wpm: Int) : RsvpEvent()
|
||||
data class OnSeekToProgress(val progress: Float) : RsvpEvent()
|
||||
data class OnSkip(val delta: Int) : RsvpEvent()
|
||||
data object OnToggleControls : RsvpEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.dueattendant149.bookshelf.data.settings.SettingsManager
|
||||
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpEngine
|
||||
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpTokenizer
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@HiltViewModel
|
||||
class RsvpModel
|
||||
@Inject
|
||||
constructor(
|
||||
private val getBookUseCase: GetBookUseCase,
|
||||
private val getTextUseCase: GetTextUseCase,
|
||||
private val updateBookUseCase: UpdateBookUseCase,
|
||||
private val settings: SettingsManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val engine = RsvpEngine(viewModelScope)
|
||||
|
||||
private val _state = MutableStateFlow(RsvpState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
private val _effects = MutableSharedFlow<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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
import org.dueattendant149.bookshelf.ui.rsvp.RsvpContent
|
||||
|
||||
@Parcelize
|
||||
data class RsvpScreen(val bookId: Int) : Screen, Parcelable {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
|
||||
@Immutable
|
||||
data class RsvpState(
|
||||
val book: Book = Book.default,
|
||||
val isLoading: Boolean = true,
|
||||
val errorMessage: String? = null,
|
||||
val showControls: Boolean = true,
|
||||
)
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.search
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.GlobalSearchUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.ListDownloadsUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.StartDownloadUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.UploadBookUseCase
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SearchState(
|
||||
val query: String = "",
|
||||
val results: List<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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.search
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.ui.search.SearchContent
|
||||
|
||||
@Parcelize
|
||||
object SearchScreen : Screen, Parcelable {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<SearchModel>()
|
||||
SearchContent(model = model)
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,8 @@ import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
|||
import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl
|
||||
import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
|
||||
import org.dueattendant149.bookshelf.domain.util.normalizeUri
|
||||
import javax.inject.Inject
|
||||
|
||||
data class BookshelfSettingsState(
|
||||
|
|
@ -108,8 +110,20 @@ class BookshelfSettingsModel
|
|||
fun save() {
|
||||
viewModelScope.launch {
|
||||
val current = _state.value
|
||||
serverSettings.setBookshelfUrl(current.bookshelfUrl)
|
||||
serverSettings.setAbsUrl(current.absUrl)
|
||||
val normalizedBookshelfUrl = current.bookshelfUrl.trim().let {
|
||||
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
|
||||
}
|
||||
val normalizedAbsUrl = current.absUrl.trim().let {
|
||||
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
|
||||
}
|
||||
_state.update {
|
||||
it.copy(
|
||||
bookshelfUrl = normalizedBookshelfUrl,
|
||||
absUrl = normalizedAbsUrl,
|
||||
)
|
||||
}
|
||||
serverSettings.setBookshelfUrl(normalizedBookshelfUrl)
|
||||
serverSettings.setAbsUrl(normalizedAbsUrl)
|
||||
serverSettings.setAbsToken(current.absToken)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import android.os.Parcelable
|
|||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen
|
||||
|
|
@ -29,9 +30,12 @@ object SettingsScreen : Screen, Parcelable {
|
|||
val settings = LocalSettings.current
|
||||
val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior()
|
||||
|
||||
val isRoot = navigator.items.collectAsStateWithLifecycle().value.size <= 1
|
||||
|
||||
SettingsContent(
|
||||
listState = listState,
|
||||
scrollBehavior = scrollBehavior,
|
||||
isRoot = isRoot,
|
||||
navigateToGeneralSettings = {
|
||||
navigator.push(GeneralSettingsScreen)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.tts
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.dueattendant149.bookshelf.data.worker.TtsDownloadWorker
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
||||
import org.dueattendant149.bookshelf.domain.use_case.tts.CreateTtsJobUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsEnginesUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsVoicesUseCase
|
||||
import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TtsState(
|
||||
val engines: List<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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.presentation.tts
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
import org.dueattendant149.bookshelf.ui.tts.TtsContent
|
||||
|
||||
@Parcelize
|
||||
data class TtsScreen(val bookRemoteId: String) : Screen, Parcelable {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val model = hiltViewModel<TtsModel>()
|
||||
val navigator = LocalNavigator.current
|
||||
TtsContent(
|
||||
model = model,
|
||||
bookRemoteId = bookRemoteId,
|
||||
navigateBack = navigator::pop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,35 +8,25 @@ package org.dueattendant149.bookshelf.ui.bookshelf
|
|||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.CloudQueue
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -50,35 +40,62 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
|
||||
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel
|
||||
import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
|
||||
import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
|
||||
import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton
|
||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
|
||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||
fun RemoteLibraryContent(
|
||||
model: RemoteLibraryViewModel,
|
||||
titleRes: Int,
|
||||
onPlayAudio: (Book) -> Unit,
|
||||
navigateToTts: (bookRemoteId: String) -> Unit,
|
||||
) {
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
val navigator = LocalNavigator.current
|
||||
val refreshState = rememberPullRefreshState(
|
||||
refreshing = state.isLoading,
|
||||
onRefresh = model::loadLibraries
|
||||
)
|
||||
|
||||
val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
|
||||
if (!state.offlineOnly) state.books
|
||||
else state.books.filter { book ->
|
||||
val status = state.cacheStatuses[book.remoteId]
|
||||
status?.state == CacheState.COMPLETED ||
|
||||
status?.state == CacheState.DOWNLOADING ||
|
||||
status?.state == CacheState.PENDING
|
||||
val sortedBooks = remember(state.books) {
|
||||
state.books.sortedByDescending { it.lastOpened ?: 0L }
|
||||
}
|
||||
|
||||
val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) {
|
||||
if (!state.offlineOnly) sortedBooks
|
||||
else sortedBooks.filter { book ->
|
||||
when (state.cacheStatuses[book.remoteId]?.state) {
|
||||
CacheState.COMPLETED,
|
||||
CacheState.DOWNLOADING,
|
||||
CacheState.PENDING -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onlineIndicator = remember(state.serverOnline) {
|
||||
if (state.serverOnline) ""
|
||||
else " • Offline"
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.bookshelf_screen)) }
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(titleRes) + onlineIndicator,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
|
|
@ -93,95 +110,71 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_libraries_header),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(state.libraries.size) { index ->
|
||||
val library = state.libraries[index]
|
||||
val selected = state.selectedLibrary?.id == library.id
|
||||
Card(
|
||||
onClick = { model.selectLibrary(library) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = library.name,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.bookshelf_library_subtitle,
|
||||
library.mediaType,
|
||||
library.itemCount
|
||||
)
|
||||
)
|
||||
},
|
||||
trailingContent = {
|
||||
if (selected) {
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_selected_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.selectedLibrary?.let { library ->
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_books_header, library.name),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.bookshelf_offline_only_label),
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Switch(
|
||||
checked = state.offlineOnly,
|
||||
onCheckedChange = { model.toggleOfflineOnly() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(visibleBooks.size) { index ->
|
||||
val book = visibleBooks[index]
|
||||
items(
|
||||
items = visibleBooks,
|
||||
key = { it.remoteId }
|
||||
) { book ->
|
||||
val cacheStatus = state.cacheStatuses[book.remoteId]
|
||||
val isCaching = state.cachingBookId == book.remoteId
|
||||
BookListItem(
|
||||
|
||||
val progress = when {
|
||||
book.hasAudio -> {
|
||||
val dur = book.audioDuration.coerceAtLeast(1L)
|
||||
(book.audioCurrentPosition.toFloat() / dur).coerceIn(0f, 1f)
|
||||
}
|
||||
else -> book.progress.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
val primaryLabel: String
|
||||
val secondaryLabel: String
|
||||
val onPrimaryClick: () -> Unit
|
||||
val onSecondaryClick: () -> Unit
|
||||
|
||||
when {
|
||||
book.hasAudio -> {
|
||||
primaryLabel = stringResource(R.string.bookshelf_play_audio_action)
|
||||
secondaryLabel = stringResource(R.string.chapters_button)
|
||||
onPrimaryClick = { onPlayAudio(book) }
|
||||
onSecondaryClick = { navigator.push(ChapterListScreen(book)) }
|
||||
}
|
||||
book.hasEbook -> {
|
||||
primaryLabel = stringResource(R.string.read)
|
||||
secondaryLabel = stringResource(R.string.tts_button)
|
||||
onPrimaryClick = { model.openBook(book) }
|
||||
onSecondaryClick = { navigateToTts(book.remoteId) }
|
||||
}
|
||||
else -> {
|
||||
primaryLabel = ""
|
||||
secondaryLabel = ""
|
||||
onPrimaryClick = {}
|
||||
onSecondaryClick = {}
|
||||
}
|
||||
}
|
||||
|
||||
BookCard(
|
||||
book = book,
|
||||
cacheStatus = cacheStatus,
|
||||
isCaching = isCaching,
|
||||
onDownload = { model.cacheBook(book) },
|
||||
onDelete = { model.deleteCache(book) },
|
||||
onRead = if (book.hasEbook) ({ model.openBook(book) }) else null
|
||||
progress = progress,
|
||||
primaryButton = {
|
||||
if (primaryLabel.isNotBlank()) {
|
||||
PrimaryActionButton(
|
||||
label = primaryLabel,
|
||||
enabled = !isCaching,
|
||||
onClick = onPrimaryClick
|
||||
)
|
||||
}
|
||||
},
|
||||
secondaryButton = {
|
||||
if (secondaryLabel.isNotBlank()) {
|
||||
SecondaryActionButton(
|
||||
label = secondaryLabel,
|
||||
onClick = onSecondaryClick
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
|
||||
if (state.isLoading && state.books.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -195,7 +188,7 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
|
||||
if (!state.isLoading && state.books.isEmpty() && state.error == null) {
|
||||
EmptyPlaceholder(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
message = stringResource(R.string.bookshelf_empty_message),
|
||||
|
|
@ -205,6 +198,7 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
)
|
||||
}
|
||||
|
||||
|
||||
state.error?.let { error ->
|
||||
ErrorPlaceholder(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
|
|
@ -226,102 +220,4 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BookListItem(
|
||||
book: Book,
|
||||
cacheStatus: CacheStatus?,
|
||||
isCaching: Boolean,
|
||||
onDownload: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRead: (() -> Unit)?,
|
||||
) {
|
||||
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
||||
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
||||
val showDelete = cacheState != CacheState.NONE
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Column {
|
||||
Text(
|
||||
text = book.author.getAsString()
|
||||
?: stringResource(R.string.unknown_author)
|
||||
)
|
||||
if (cacheState != CacheState.NONE) {
|
||||
Text(
|
||||
text = cacheStatusLabel(cacheState),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingContent = {
|
||||
Row {
|
||||
if (showDownload) {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Download,
|
||||
contentDescription = stringResource(R.string.bookshelf_download_action)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showDelete) {
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.bookshelf_delete_cache_action)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (cacheState == CacheState.DOWNLOADING) {
|
||||
LinearProgressIndicator(
|
||||
progress = { cacheStatus?.progress?.coerceIn(0f, 1f) ?: 0f },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
onRead?.let { read ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 12.dp),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
Button(
|
||||
onClick = read,
|
||||
enabled = !isCaching
|
||||
) {
|
||||
Text(stringResource(R.string.read))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun cacheStatusLabel(state: CacheState): String =
|
||||
when (state) {
|
||||
CacheState.NONE -> ""
|
||||
CacheState.PENDING -> stringResource(R.string.bookshelf_cache_pending)
|
||||
CacheState.DOWNLOADING -> stringResource(R.string.bookshelf_cache_downloading)
|
||||
CacheState.COMPLETED -> stringResource(R.string.bookshelf_cache_completed)
|
||||
CacheState.FAILED -> stringResource(R.string.bookshelf_cache_failed)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
package org.dueattendant149.bookshelf.ui.common.components.book
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
|
||||
@Composable
|
||||
fun BookCard(
|
||||
book: Book,
|
||||
progress: Float,
|
||||
primaryButton: @Composable (() -> Unit)? = null,
|
||||
secondaryButton: @Composable (() -> Unit)? = null,
|
||||
coverBadge: (@Composable () -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
val coercedProgress = progress.coerceIn(0f, 1f)
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.shadow(elevation = 4.dp, shape = shape, clip = false),
|
||||
shape = shape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Fixed-height vertical cover on the left
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(120.dp)
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (book.coverUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalContext.current)
|
||||
.data(Uri.parse(book.coverUrl))
|
||||
.crossfade(200)
|
||||
.build(),
|
||||
contentDescription = stringResource(R.string.cover_image_content_desc),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
CoverPlaceholder(book = book)
|
||||
}
|
||||
coverBadge?.invoke()
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// Title / author / progress in the middle
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically)
|
||||
) {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = book.author.getAsString() ?: stringResource(R.string.unknown_author),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { coercedProgress },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(12.dp))
|
||||
|
||||
// Two pill buttons stacked vertically on the right
|
||||
Column(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
secondaryButton?.invoke()
|
||||
primaryButton?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CoverPlaceholder(book: Book) {
|
||||
val seed = remember(book.title, book.author.getAsString()) {
|
||||
book.title + book.author.getAsString()
|
||||
}
|
||||
val color = rememberCoverColor(seed = seed)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = book.title.take(1).uppercase(),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberCoverColor(seed: String): Color {
|
||||
val palette = remember {
|
||||
listOf(
|
||||
Color(0xFF1E88E5),
|
||||
Color(0xFF43A047),
|
||||
Color(0xFFE53935),
|
||||
Color(0xFF8E24AA),
|
||||
Color(0xFFFDD835),
|
||||
Color(0xFF00897B),
|
||||
Color(0xFF3949AB),
|
||||
Color(0xFFD81B60),
|
||||
)
|
||||
}
|
||||
val index = (seed.hashCode() % palette.size).let { if (it < 0) it + palette.size else it }
|
||||
return palette[index]
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package org.dueattendant149.bookshelf.ui.common.components.book
|
||||
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun PrimaryActionButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = Modifier
|
||||
.widthIn(min = 84.dp)
|
||||
.height(40.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondaryActionButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = Modifier
|
||||
.widthIn(min = 84.dp)
|
||||
.height(40.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
/*
|
||||
* Book's Story — free and open-source Material You eBook reader.
|
||||
* Copyright (C) 2024-2026 Acclorite
|
||||
* SPDX-License-Identifier: GPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.dueattendant149.bookshelf.ui.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CloudDownload
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.dueattendant149.bookshelf.R
|
||||
import org.dueattendant149.bookshelf.presentation.player.PlayerState
|
||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlayerContent(
|
||||
state: StateFlow<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)
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
|
|||
import org.dueattendant149.bookshelf.ui.common.model.Direction
|
||||
import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition
|
||||
import org.dueattendant149.bookshelf.ui.theme.readerBarsColor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ReaderBottomBar(
|
||||
|
|
@ -60,6 +61,10 @@ fun ReaderBottomBar(
|
|||
lockMenu: Boolean,
|
||||
checkpoints: List<Checkpoint>,
|
||||
bottomBarPadding: Dp,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
currentPage: Int,
|
||||
onChangePage: (Int) -> Unit,
|
||||
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
|
||||
scroll: (ReaderEvent.OnScroll) -> Unit,
|
||||
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
|
||||
|
|
@ -138,13 +143,17 @@ fun ReaderBottomBar(
|
|||
}
|
||||
},
|
||||
slider = {
|
||||
ReaderBottomBarSlider(
|
||||
book = book,
|
||||
lockMenu = lockMenu,
|
||||
listState = listState,
|
||||
scroll = scroll,
|
||||
changeProgress = changeProgress
|
||||
)
|
||||
ReaderBottomBarSlider(
|
||||
book = book,
|
||||
lockMenu = lockMenu,
|
||||
listState = listState,
|
||||
readerPagination = readerPagination,
|
||||
pages = pages,
|
||||
currentPage = currentPage,
|
||||
onChangePage = onChangePage,
|
||||
scroll = scroll,
|
||||
changeProgress = changeProgress
|
||||
)
|
||||
},
|
||||
indicator = {
|
||||
ReaderBottomBarCheckpointsIndicator(
|
||||
|
|
@ -240,6 +249,10 @@ private fun ReaderBottomBarSlider(
|
|||
book: Book,
|
||||
lockMenu: Boolean,
|
||||
listState: LazyListState,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
currentPage: Int,
|
||||
onChangePage: (Int) -> Unit,
|
||||
scroll: (ReaderEvent.OnScroll) -> Unit,
|
||||
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
|
||||
) {
|
||||
|
|
@ -255,7 +268,17 @@ private fun ReaderBottomBarSlider(
|
|||
value = progress,
|
||||
enabled = !lockMenu,
|
||||
onValueChange = {
|
||||
if (listState.layoutInfo.totalItemsCount > 0) {
|
||||
if (readerPagination && pages.isNotEmpty()) {
|
||||
val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex)
|
||||
onChangePage(page)
|
||||
changeProgress(
|
||||
ReaderEvent.OnChangeProgress(
|
||||
progress = it,
|
||||
firstVisibleItemIndex = pages[page].startTextIndex,
|
||||
firstVisibleItemOffset = 0
|
||||
)
|
||||
)
|
||||
} else if (listState.layoutInfo.totalItemsCount > 0) {
|
||||
scroll(ReaderEvent.OnScroll(it))
|
||||
changeProgress(
|
||||
ReaderEvent.OnChangeProgress(
|
||||
|
|
|
|||
|
|
@ -58,6 +58,10 @@ fun ReaderContent(
|
|||
checkpoints: List<Checkpoint>,
|
||||
showMenu: Boolean,
|
||||
lockMenu: Boolean,
|
||||
readerPagination: Boolean,
|
||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||
currentPage: Int,
|
||||
onPageChanged: (Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
verticalPadding: Dp,
|
||||
horizontalGesture: ReaderHorizontalGesture,
|
||||
|
|
@ -111,6 +115,7 @@ fun ReaderContent(
|
|||
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
||||
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit,
|
||||
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
||||
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
|
||||
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
||||
) {
|
||||
ReaderBottomSheet(
|
||||
|
|
@ -120,29 +125,33 @@ fun ReaderContent(
|
|||
)
|
||||
|
||||
if (isLoading || errorMessage == null) {
|
||||
ReaderScaffold(
|
||||
book = book,
|
||||
text = text,
|
||||
listState = listState,
|
||||
currentChapter = currentChapter,
|
||||
nestedScrollConnection = nestedScrollConnection,
|
||||
fastColorPresetChange = fastColorPresetChange,
|
||||
perceptionExpander = perceptionExpander,
|
||||
perceptionExpanderPadding = perceptionExpanderPadding,
|
||||
perceptionExpanderThickness = perceptionExpanderThickness,
|
||||
horizontalLimiter = horizontalLimiter,
|
||||
horizontalLimiterHeight = horizontalLimiterHeight,
|
||||
horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
|
||||
horizontalLimiterRuler = horizontalLimiterRuler,
|
||||
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
||||
horizontalLimiterDimming = horizontalLimiterDimming,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
isLoading = isLoading,
|
||||
checkpoints = checkpoints,
|
||||
showMenu = showMenu,
|
||||
lockMenu = lockMenu,
|
||||
contentPadding = contentPadding,
|
||||
verticalPadding = verticalPadding,
|
||||
ReaderScaffold(
|
||||
book = book,
|
||||
text = text,
|
||||
listState = listState,
|
||||
currentChapter = currentChapter,
|
||||
nestedScrollConnection = nestedScrollConnection,
|
||||
fastColorPresetChange = fastColorPresetChange,
|
||||
perceptionExpander = perceptionExpander,
|
||||
perceptionExpanderPadding = perceptionExpanderPadding,
|
||||
perceptionExpanderThickness = perceptionExpanderThickness,
|
||||
horizontalLimiter = horizontalLimiter,
|
||||
horizontalLimiterHeight = horizontalLimiterHeight,
|
||||
horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
|
||||
horizontalLimiterRuler = horizontalLimiterRuler,
|
||||
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
||||
horizontalLimiterDimming = horizontalLimiterDimming,
|
||||
currentChapterProgress = currentChapterProgress,
|
||||
isLoading = isLoading,
|
||||
checkpoints = checkpoints,
|
||||
showMenu = showMenu,
|
||||
lockMenu = lockMenu,
|
||||
readerPagination = readerPagination,
|
||||
pages = pages,
|
||||
currentPage = currentPage,
|
||||
onPageChanged = onPageChanged,
|
||||
contentPadding = contentPadding,
|
||||
verticalPadding = verticalPadding,
|
||||
horizontalGesture = horizontalGesture,
|
||||
horizontalGestureScroll = horizontalGestureScroll,
|
||||
horizontalGestureSensitivity = horizontalGestureSensitivity,
|
||||
|
|
@ -191,7 +200,8 @@ fun ReaderContent(
|
|||
showSettingsBottomSheet = showSettingsBottomSheet,
|
||||
showChaptersDrawer = showChaptersDrawer,
|
||||
navigateBack = navigateBack,
|
||||
navigateToBookInfo = navigateToBookInfo
|
||||
navigateToBookInfo = navigateToBookInfo,
|
||||
navigateToRsvp = navigateToRsvp
|
||||
)
|
||||
} else {
|
||||
ReaderErrorPlaceholder(
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import org.dueattendant149.bookshelf.R
|
|||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||
import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen
|
||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderEffect
|
||||
import org.dueattendant149.bookshelf.presentation.rsvp.RsvpScreen
|
||||
import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
|
||||
import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity
|
||||
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
||||
|
|
@ -183,6 +184,10 @@ fun ReaderEffects(
|
|||
saveInBackStack = false
|
||||
)
|
||||
}
|
||||
|
||||
is ReaderEffect.OnNavigateToRsvp -> {
|
||||
navigator.push(RsvpScreen(bookId = effect.bookId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue