Compare commits
No commits in common. "f7307f41999d91a7dbfe3c5b02e269fe2bea36b5" and "c8c1503d8c37e99c7d43c2307897dba56517266e" have entirely different histories.
f7307f4199
...
c8c1503d8c
136 changed files with 1248 additions and 9628 deletions
46
AGENTS.md
46
AGENTS.md
|
|
@ -13,7 +13,7 @@
|
||||||
- **Аудио:** ExoPlayer (Media3) + MediaSession
|
- **Аудио:** ExoPlayer (Media3) + MediaSession
|
||||||
- **Фоновые задачи:** WorkManager
|
- **Фоновые задачи:** WorkManager
|
||||||
- **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark)
|
- **Читалка:** собственная Book's Story (jsoup, pdfbox, commonmark)
|
||||||
- **minSdk:** 31
|
- **minSdk:** 26 (план — поднять до 31 позже)
|
||||||
- **compileSdk/targetSdk:** 36
|
- **compileSdk/targetSdk:** 36
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
@ -66,46 +66,14 @@
|
||||||
|
|
||||||
- Phase 1: fork + package rename completed.
|
- Phase 1: fork + package rename completed.
|
||||||
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
|
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
|
||||||
- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17→18) completed.
|
- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed.
|
||||||
- Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
|
- Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
|
||||||
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
||||||
- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) completed.
|
- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged.
|
||||||
- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) completed.
|
- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged.
|
||||||
- Phase 6: feature integration on `feature/ui-polish` completed:
|
- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model.
|
||||||
- RemoteLibrary cache UI (offline-only filter, download/delete, progress indicator).
|
- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`.
|
||||||
- Remote ebook opening in the existing reader.
|
- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync.
|
||||||
- Audiobook player with ExoPlayer + MediaSession.
|
|
||||||
- TTS jobs screen via bookshelf-api.
|
|
||||||
- Reading and playback progress sync with periodic WorkManager worker.
|
|
||||||
- Current: `feature/ui-polish` builds and produces `app-debug.apk` (~58 MB). Debug APK uploaded as `KRait-debug-15.apk` to ownCloud + caddy download site.
|
|
||||||
- Polished: PlayerContent and TtsContent labels moved to `strings.xml`; TTS WorkManager observer leak fixed (`TtsModel` removes observer in `onCleared`).
|
|
||||||
- Raised `minSdk` from 26 to 31.
|
|
||||||
- Phase 7 (nav redesign): `MainActivity` tabs = Книги | Аудиокниги | Поиск | Настройки.
|
|
||||||
- `BooksModel` / `AudiobooksModel` extend `RemoteLibraryViewModel` with `mediaTypeFilter`.
|
|
||||||
- Library filter uses name fallback ("Books" / "Audiobooks") because ABS returns `mediaType: "book"` for both.
|
|
||||||
- `SearchModel` + `SearchContent` for global Librarr search (`/api/v1/search`).
|
|
||||||
- `ServerStatusMonitor` (singleton, pings `/health` every 30 s) → auto offline-only when down.
|
|
||||||
- `SettingsScreen` is a root tab — back button hidden when stack size ≤ 1.
|
|
||||||
- Phase 8 (RSVP speed reading): core tokenizer + engine + UI.
|
|
||||||
- `domain/model/rsvp/` — `RsvpToken`, `OrpTable`, `RsvpTokenizer`, `RsvpEngine`.
|
|
||||||
- `presentation/rsvp/` — `RsvpScreen`, `RsvpModel`, `RsvpState/Event/Effect`.
|
|
||||||
- `ui/rsvp/RsvpContent.kt` — word stage with prefix/pivot/suffix, focus guide ▲▼, controls bar.
|
|
||||||
- `ReaderTopBar` got a ⚡ button → `RsvpScreen(bookId)`.
|
|
||||||
- Persists reading progress on close via `UpdateBookUseCase`.
|
|
||||||
- Settings: `rsvpWpm` (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
|
## WARNs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
id("org.jetbrains.kotlin.android")
|
||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
id("org.jetbrains.kotlin.plugin.serialization")
|
|
||||||
id("com.google.devtools.ksp")
|
id("com.google.devtools.ksp")
|
||||||
id("com.google.dagger.hilt.android")
|
id("com.google.dagger.hilt.android")
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
|
|
@ -17,10 +16,10 @@ android {
|
||||||
// Default configuration
|
// Default configuration
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "org.dueattendant149.bookshelf"
|
applicationId = "org.dueattendant149.bookshelf"
|
||||||
minSdk = 31
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 27
|
versionCode = 14
|
||||||
versionName = "1.9.11"
|
versionName = "1.8.0"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
@ -180,14 +179,4 @@ dependencies {
|
||||||
|
|
||||||
// Background work (cache, TTS sync)
|
// Background work (cache, TTS sync)
|
||||||
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
implementation("androidx.work:work-runtime-ktx:2.10.0")
|
||||||
implementation("androidx.hilt:hilt-work:1.2.0")
|
|
||||||
|
|
||||||
// Test
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
|
||||||
testImplementation("com.squareup.retrofit2:retrofit:2.11.0")
|
|
||||||
testImplementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
|
|
||||||
testImplementation("com.squareup.okhttp3:okhttp:4.12.0")
|
|
||||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0")
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,500 +0,0 @@
|
||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 18,
|
|
||||||
"identityHash": "989c23bd1fdbd69a7d65b94ae6cf381b",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "BookEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "author",
|
|
||||||
"columnName": "author",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "description",
|
|
||||||
"columnName": "description",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "filePath",
|
|
||||||
"columnName": "filePath",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scrollIndex",
|
|
||||||
"columnName": "scrollIndex",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scrollOffset",
|
|
||||||
"columnName": "scrollOffset",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "progress",
|
|
||||||
"columnName": "progress",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "image",
|
|
||||||
"columnName": "image",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "categories",
|
|
||||||
"columnName": "categories",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "'[]'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remoteId",
|
|
||||||
"columnName": "remoteId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "libraryId",
|
|
||||||
"columnName": "libraryId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "mediaType",
|
|
||||||
"columnName": "mediaType",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "hasAudio",
|
|
||||||
"columnName": "hasAudio",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "hasEbook",
|
|
||||||
"columnName": "hasEbook",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "audioDuration",
|
|
||||||
"columnName": "audioDuration",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "audioCurrentFile",
|
|
||||||
"columnName": "audioCurrentFile",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "audioCurrentPosition",
|
|
||||||
"columnName": "audioCurrentPosition",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "coverUrl",
|
|
||||||
"columnName": "coverUrl",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncedAt",
|
|
||||||
"columnName": "lastSyncedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "HistoryEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "time",
|
|
||||||
"columnName": "time",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "ColorPresetEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "backgroundColor",
|
|
||||||
"columnName": "backgroundColor",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fontColor",
|
|
||||||
"columnName": "fontColor",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isSelected",
|
|
||||||
"columnName": "isSelected",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "order",
|
|
||||||
"columnName": "order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "CategoryEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "order",
|
|
||||||
"columnName": "order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrder",
|
|
||||||
"columnName": "sortOrder",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "'LAST_READ'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrderDescending",
|
|
||||||
"columnName": "sortOrderDescending",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "audio_file",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fileId",
|
|
||||||
"columnName": "fileId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remotePath",
|
|
||||||
"columnName": "remotePath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "localPath",
|
|
||||||
"columnName": "localPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "duration",
|
|
||||||
"columnName": "duration",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "order",
|
|
||||||
"columnName": "order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_audio_file_bookId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "BookEntity",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "ebook_file",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fileId",
|
|
||||||
"columnName": "fileId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "format",
|
|
||||||
"columnName": "format",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remotePath",
|
|
||||||
"columnName": "remotePath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "localPath",
|
|
||||||
"columnName": "localPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_ebook_file_bookId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "BookEntity",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_file",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "type",
|
|
||||||
"columnName": "type",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remoteUrl",
|
|
||||||
"columnName": "remoteUrl",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "localPath",
|
|
||||||
"columnName": "localPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "status",
|
|
||||||
"columnName": "status",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "progress",
|
|
||||||
"columnName": "progress",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "createdAt",
|
|
||||||
"columnName": "createdAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_cached_file_bookId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_cached_file_type",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"type"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '989c23bd1fdbd69a7d65b94ae6cf381b')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,507 +0,0 @@
|
||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 19,
|
|
||||||
"identityHash": "7a70be4a2d1b3fd8f89dda543d18a6b4",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "BookEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `itemType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "author",
|
|
||||||
"columnName": "author",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "description",
|
|
||||||
"columnName": "description",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "filePath",
|
|
||||||
"columnName": "filePath",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scrollIndex",
|
|
||||||
"columnName": "scrollIndex",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "scrollOffset",
|
|
||||||
"columnName": "scrollOffset",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "progress",
|
|
||||||
"columnName": "progress",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "image",
|
|
||||||
"columnName": "image",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "categories",
|
|
||||||
"columnName": "categories",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "'[]'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remoteId",
|
|
||||||
"columnName": "remoteId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "libraryId",
|
|
||||||
"columnName": "libraryId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "mediaType",
|
|
||||||
"columnName": "mediaType",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "itemType",
|
|
||||||
"columnName": "itemType",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "hasAudio",
|
|
||||||
"columnName": "hasAudio",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "hasEbook",
|
|
||||||
"columnName": "hasEbook",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "audioDuration",
|
|
||||||
"columnName": "audioDuration",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "audioCurrentFile",
|
|
||||||
"columnName": "audioCurrentFile",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "audioCurrentPosition",
|
|
||||||
"columnName": "audioCurrentPosition",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "coverUrl",
|
|
||||||
"columnName": "coverUrl",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "''"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastSyncedAt",
|
|
||||||
"columnName": "lastSyncedAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "0"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "HistoryEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "time",
|
|
||||||
"columnName": "time",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "ColorPresetEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "name",
|
|
||||||
"columnName": "name",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "backgroundColor",
|
|
||||||
"columnName": "backgroundColor",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fontColor",
|
|
||||||
"columnName": "fontColor",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "isSelected",
|
|
||||||
"columnName": "isSelected",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "order",
|
|
||||||
"columnName": "order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "CategoryEntity",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "title",
|
|
||||||
"columnName": "title",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "order",
|
|
||||||
"columnName": "order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrder",
|
|
||||||
"columnName": "sortOrder",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "'LAST_READ'"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sortOrderDescending",
|
|
||||||
"columnName": "sortOrderDescending",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true,
|
|
||||||
"defaultValue": "1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "audio_file",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fileId",
|
|
||||||
"columnName": "fileId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remotePath",
|
|
||||||
"columnName": "remotePath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "localPath",
|
|
||||||
"columnName": "localPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "duration",
|
|
||||||
"columnName": "duration",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "order",
|
|
||||||
"columnName": "order",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_audio_file_bookId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "BookEntity",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "ebook_file",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "fileId",
|
|
||||||
"columnName": "fileId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "format",
|
|
||||||
"columnName": "format",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remotePath",
|
|
||||||
"columnName": "remotePath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "localPath",
|
|
||||||
"columnName": "localPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_ebook_file_bookId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": [
|
|
||||||
{
|
|
||||||
"table": "BookEntity",
|
|
||||||
"onDelete": "CASCADE",
|
|
||||||
"onUpdate": "NO ACTION",
|
|
||||||
"columns": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"referencedColumns": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "cached_file",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "bookId",
|
|
||||||
"columnName": "bookId",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "type",
|
|
||||||
"columnName": "type",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "remoteUrl",
|
|
||||||
"columnName": "remoteUrl",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "localPath",
|
|
||||||
"columnName": "localPath",
|
|
||||||
"affinity": "TEXT"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "status",
|
|
||||||
"columnName": "status",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "progress",
|
|
||||||
"columnName": "progress",
|
|
||||||
"affinity": "REAL",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "createdAt",
|
|
||||||
"columnName": "createdAt",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": true,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_cached_file_bookId",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"bookId"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_cached_file_type",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"type"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a70be4a2d1b3fd8f89dda543d18a6b4')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -22,12 +22,6 @@
|
||||||
tools:node="remove"
|
tools:node="remove"
|
||||||
tools:ignore="ScopedStorage" />
|
tools:ignore="ScopedStorage" />
|
||||||
|
|
||||||
<!-- Audio playback -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".Application"
|
android:name=".Application"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
|
@ -62,16 +56,6 @@
|
||||||
android:theme="@style/BookStory"
|
android:theme="@style/BookStory"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<!-- Audiobook playback service -->
|
|
||||||
<service
|
|
||||||
android:name=".data.playback.AudioPlaybackService"
|
|
||||||
android:exported="false"
|
|
||||||
android:foregroundServiceType="mediaPlayback">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<!-- Save app locales -->
|
<!-- Save app locales -->
|
||||||
<service
|
<service
|
||||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||||
|
|
|
||||||
|
|
@ -7,36 +7,13 @@
|
||||||
package org.dueattendant149.bookshelf
|
package org.dueattendant149.bookshelf
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.hilt.work.HiltWorkerFactory
|
|
||||||
import androidx.work.Configuration
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
|
||||||
import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class Application : Application(), Configuration.Provider {
|
class Application : Application() {
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workerFactory: HiltWorkerFactory
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var workManager: WorkManager
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var serverStatusMonitor: ServerStatusMonitor
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
||||||
ProgressSyncWorker.schedule(workManager)
|
|
||||||
serverStatusMonitor.start()
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override val workManagerConfiguration: Configuration
|
|
||||||
get() = Configuration.Builder()
|
|
||||||
.setWorkerFactory(workerFactory)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
@ -10,9 +10,8 @@ import kotlinx.collections.immutable.toPersistentList
|
||||||
import org.dueattendant149.bookshelf.core.language.Language
|
import org.dueattendant149.bookshelf.core.language.Language
|
||||||
|
|
||||||
object CoreData {
|
object CoreData {
|
||||||
val defaultLanguage = Language.fromLanguageTag(languageTag = "ru") // Russian
|
val defaultLanguage = Language.fromLanguageTag(languageTag = "en") // English
|
||||||
val languages = listOf(
|
val languages = listOf(
|
||||||
Language.fromLanguageTag(languageTag = "ru"), // Russian
|
|
||||||
Language.fromLanguageTag(languageTag = "en"), // English
|
Language.fromLanguageTag(languageTag = "en"), // English
|
||||||
Language.fromLanguageTag(languageTag = "uk"), // Ukrainian
|
Language.fromLanguageTag(languageTag = "uk"), // Ukrainian
|
||||||
Language.fromLanguageTag(languageTag = "de"), // German
|
Language.fromLanguageTag(languageTag = "de"), // German
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,6 @@ object AppModule {
|
||||||
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
|
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
|
||||||
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity
|
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity
|
||||||
DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables
|
DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables
|
||||||
DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields
|
|
||||||
DatabaseHelper.MANUAL_MIGRATION_18_19, // add itemType column
|
|
||||||
).allowMainThreadQueries().build().also { database ->
|
).allowMainThreadQueries().build().also { database ->
|
||||||
// Additional Migrations
|
// Additional Migrations
|
||||||
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)
|
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParser
|
||||||
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
|
import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl
|
||||||
import org.dueattendant149.bookshelf.data.parser.text.TextParser
|
import org.dueattendant149.bookshelf.data.parser.text.TextParser
|
||||||
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
|
import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.AudiobookshelfRepositoryImpl
|
|
||||||
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl
|
||||||
|
|
@ -36,10 +35,7 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.ProgressRepositoryImpl
|
|
||||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.RemoteTtsRepositoryImpl
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
||||||
|
|
@ -47,9 +43,7 @@ import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
|
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
|
import org.dueattendant149.bookshelf.domain.repository.HistoryRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
|
import org.dueattendant149.bookshelf.domain.repository.PermissionRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -109,24 +103,6 @@ abstract class RepositoryModule {
|
||||||
cacheRepositoryImpl: CacheRepositoryImpl
|
cacheRepositoryImpl: CacheRepositoryImpl
|
||||||
): CacheRepository
|
): CacheRepository
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindAudiobookshelfRepository(
|
|
||||||
audiobookshelfRepositoryImpl: AudiobookshelfRepositoryImpl
|
|
||||||
): AudiobookshelfRepository
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindRemoteTtsRepository(
|
|
||||||
remoteTtsRepositoryImpl: RemoteTtsRepositoryImpl
|
|
||||||
): RemoteTtsRepository
|
|
||||||
|
|
||||||
@Binds
|
|
||||||
@Singleton
|
|
||||||
abstract fun bindProgressRepository(
|
|
||||||
progressRepositoryImpl: ProgressRepositoryImpl
|
|
||||||
): ProgressRepository
|
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindBookMapper(
|
abstract fun bindBookMapper(
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ data class BookEntity(
|
||||||
val libraryId: String = "",
|
val libraryId: String = "",
|
||||||
@ColumnInfo(defaultValue = "")
|
@ColumnInfo(defaultValue = "")
|
||||||
val mediaType: String = "",
|
val mediaType: String = "",
|
||||||
@ColumnInfo(defaultValue = "")
|
|
||||||
val itemType: String = "",
|
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val hasAudio: Boolean = false,
|
val hasAudio: Boolean = false,
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
|
|
@ -41,10 +39,6 @@ data class BookEntity(
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val audioDuration: Long = 0L,
|
val audioDuration: Long = 0L,
|
||||||
@ColumnInfo(defaultValue = "")
|
@ColumnInfo(defaultValue = "")
|
||||||
val audioCurrentFile: String = "",
|
|
||||||
@ColumnInfo(defaultValue = "0")
|
|
||||||
val audioCurrentPosition: Long = 0L,
|
|
||||||
@ColumnInfo(defaultValue = "")
|
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val lastSyncedAt: Long = 0L,
|
val lastSyncedAt: Long = 0L,
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,6 @@ interface BookDao {
|
||||||
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
|
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
|
||||||
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
|
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
|
||||||
|
|
||||||
@Query("SELECT * FROM bookentity WHERE remoteId != ''")
|
|
||||||
suspend fun findBooksWithRemoteId(): List<BookEntity>
|
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteBook(book: BookEntity): Int
|
suspend fun deleteBook(book: BookEntity): Int
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import java.io.File
|
||||||
EbookFileEntity::class,
|
EbookFileEntity::class,
|
||||||
CachedFileEntity::class,
|
CachedFileEntity::class,
|
||||||
],
|
],
|
||||||
version = 19,
|
version = 17,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(1, 2),
|
AutoMigration(1, 2),
|
||||||
AutoMigration(2, 3),
|
AutoMigration(2, 3),
|
||||||
|
|
@ -203,25 +203,6 @@ object DatabaseHelper {
|
||||||
@DeleteTable("CategorySortEntity")
|
@DeleteTable("CategorySortEntity")
|
||||||
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
|
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
|
||||||
|
|
||||||
val MANUAL_MIGRATION_18_19 = object : Migration(18, 19) {
|
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
|
||||||
database.execSQL(
|
|
||||||
"ALTER TABLE BookEntity ADD COLUMN itemType TEXT NOT NULL DEFAULT ''"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) {
|
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
|
||||||
database.execSQL(
|
|
||||||
"ALTER TABLE BookEntity ADD COLUMN audioCurrentFile TEXT NOT NULL DEFAULT ''"
|
|
||||||
)
|
|
||||||
database.execSQL(
|
|
||||||
"ALTER TABLE BookEntity ADD COLUMN audioCurrentPosition INTEGER NOT NULL DEFAULT 0"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
|
val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
// Add remote / bookshelf columns to BookEntity
|
// Add remote / bookshelf columns to BookEntity
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,9 @@ class BookMapperImpl @Inject constructor() : BookMapper {
|
||||||
remoteId = book.remoteId,
|
remoteId = book.remoteId,
|
||||||
libraryId = book.libraryId,
|
libraryId = book.libraryId,
|
||||||
mediaType = book.mediaType,
|
mediaType = book.mediaType,
|
||||||
itemType = book.itemType,
|
|
||||||
hasAudio = book.hasAudio,
|
hasAudio = book.hasAudio,
|
||||||
hasEbook = book.hasEbook,
|
hasEbook = book.hasEbook,
|
||||||
audioDuration = book.audioDuration,
|
audioDuration = book.audioDuration,
|
||||||
audioCurrentFile = book.audioCurrentFile,
|
|
||||||
audioCurrentPosition = book.audioCurrentPosition,
|
|
||||||
coverUrl = book.coverUrl,
|
coverUrl = book.coverUrl,
|
||||||
lastSyncedAt = book.lastSyncedAt,
|
lastSyncedAt = book.lastSyncedAt,
|
||||||
)
|
)
|
||||||
|
|
@ -59,12 +56,9 @@ class BookMapperImpl @Inject constructor() : BookMapper {
|
||||||
remoteId = bookEntity.remoteId,
|
remoteId = bookEntity.remoteId,
|
||||||
libraryId = bookEntity.libraryId,
|
libraryId = bookEntity.libraryId,
|
||||||
mediaType = bookEntity.mediaType,
|
mediaType = bookEntity.mediaType,
|
||||||
itemType = bookEntity.itemType,
|
|
||||||
hasAudio = bookEntity.hasAudio,
|
hasAudio = bookEntity.hasAudio,
|
||||||
hasEbook = bookEntity.hasEbook,
|
hasEbook = bookEntity.hasEbook,
|
||||||
audioDuration = bookEntity.audioDuration,
|
audioDuration = bookEntity.audioDuration,
|
||||||
audioCurrentFile = bookEntity.audioCurrentFile,
|
|
||||||
audioCurrentPosition = bookEntity.audioCurrentPosition,
|
|
||||||
coverUrl = bookEntity.coverUrl,
|
coverUrl = bookEntity.coverUrl,
|
||||||
lastSyncedAt = bookEntity.lastSyncedAt,
|
lastSyncedAt = bookEntity.lastSyncedAt,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.playback
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.session.MediaSession
|
|
||||||
import androidx.media3.session.MediaSessionService
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.presentation.main.MainActivity
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
class AudioPlaybackService : MediaSessionService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var exoPlayer: ExoPlayer
|
|
||||||
|
|
||||||
private var mediaSession: MediaSession? = null
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
val intent = Intent(this, MainActivity::class.java).apply {
|
|
||||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
||||||
}
|
|
||||||
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
} else {
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
}
|
|
||||||
val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
|
|
||||||
mediaSession = MediaSession.Builder(this, exoPlayer)
|
|
||||||
.setSessionActivity(sessionActivity)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
intent?.getParcelableExtra(EXTRA_BOOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
val notification = buildNotification(book)
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
|
|
||||||
return mediaSession
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
val player = mediaSession?.player ?: exoPlayer
|
|
||||||
if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
mediaSession?.run {
|
|
||||||
player.release()
|
|
||||||
release()
|
|
||||||
}
|
|
||||||
mediaSession = null
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
getString(R.string.audio_playback_channel),
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = getString(R.string.audio_playback_channel_description)
|
|
||||||
}
|
|
||||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildNotification(book: Book?): Notification {
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(book?.title ?: getString(R.string.app_name))
|
|
||||||
.setContentText(book?.author?.getAsString() ?: "")
|
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setSilent(true)
|
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_BOOK = "extra_book"
|
|
||||||
private const val CHANNEL_ID = "audio_playback"
|
|
||||||
private const val NOTIFICATION_ID = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.playback.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.media3.database.StandaloneDatabaseProvider
|
|
||||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object CacheModule {
|
|
||||||
|
|
||||||
private const val CACHE_DIR_NAME = "audiobook_cache"
|
|
||||||
private const val CACHE_SIZE_BYTES = 512L * 1024 * 1024 // 512 MB
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideSimpleCache(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): SimpleCache {
|
|
||||||
val cacheDir = File(context.externalCacheDir ?: context.cacheDir, CACHE_DIR_NAME)
|
|
||||||
if (!cacheDir.exists()) {
|
|
||||||
cacheDir.mkdirs()
|
|
||||||
}
|
|
||||||
return SimpleCache(
|
|
||||||
cacheDir,
|
|
||||||
LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES),
|
|
||||||
StandaloneDatabaseProvider(context),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.playback.di
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory holder for the audiobookshelf bearer token used by playback
|
|
||||||
* components. [refresh] must be called after the token changes or before a
|
|
||||||
* fresh playback session starts.
|
|
||||||
*/
|
|
||||||
@Singleton
|
|
||||||
class PlaybackAuthProvider @Inject constructor() {
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
var token: String = ""
|
|
||||||
private set
|
|
||||||
|
|
||||||
suspend fun refresh(serverSettings: ServerSettings) {
|
|
||||||
token = serverSettings.getAbsToken() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setToken(value: String) {
|
|
||||||
token = value.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.playback.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.media3.common.AudioAttributes
|
|
||||||
import androidx.media3.common.C
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object PlaybackModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun providePlaybackAuthProvider(): PlaybackAuthProvider = PlaybackAuthProvider()
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideExoPlayer(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
authProvider: PlaybackAuthProvider,
|
|
||||||
okHttpClient: OkHttpClient,
|
|
||||||
): ExoPlayer {
|
|
||||||
val client = okHttpClient.newBuilder()
|
|
||||||
.addInterceptor(AuthorizationInterceptor(authProvider::token))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val dataSourceFactory = OkHttpDataSource.Factory(client)
|
|
||||||
val audioAttributes = AudioAttributes.Builder()
|
|
||||||
.setUsage(C.USAGE_MEDIA)
|
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
return ExoPlayer.Builder(context)
|
|
||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(dataSourceFactory))
|
|
||||||
.setAudioAttributes(audioAttributes, true)
|
|
||||||
.setHandleAudioBecomingNoisy(true)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.remote
|
|
||||||
|
|
||||||
import okhttp3.Interceptor
|
|
||||||
import okhttp3.Response
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds an `Authorization: Bearer <token>` header when a non-blank token is
|
|
||||||
* available. The token is read lazily on every request so it can be updated
|
|
||||||
* without rebuilding the OkHttp client.
|
|
||||||
*/
|
|
||||||
class AuthorizationInterceptor(
|
|
||||||
private val tokenProvider: () -> String?,
|
|
||||||
) : Interceptor {
|
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
|
||||||
val request = chain.request()
|
|
||||||
val token = tokenProvider()?.trim()
|
|
||||||
return if (!token.isNullOrBlank()) {
|
|
||||||
chain.proceed(
|
|
||||||
request.newBuilder()
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
chain.proceed(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,8 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.model.AbsItemResponse
|
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
|
|
@ -38,15 +34,4 @@ interface AudiobookshelfApiService {
|
||||||
suspend fun getProgress(
|
suspend fun getProgress(
|
||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): Response<String>
|
): Response<String>
|
||||||
|
|
||||||
@GET("api/items/{itemId}")
|
|
||||||
suspend fun getItem(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<AbsItemResponse>
|
|
||||||
|
|
||||||
@POST("api/me/progress/{itemId}")
|
|
||||||
suspend fun updateProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Body request: PlaybackProgressUpdateRequest,
|
|
||||||
): Response<String>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for updating media progress directly on an Audiobookshelf server.
|
|
||||||
*
|
|
||||||
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlaybackProgressUpdateRequest(
|
|
||||||
@SerialName("currentTime")
|
|
||||||
val currentTime: Double,
|
|
||||||
@SerialName("duration")
|
|
||||||
val duration: Double,
|
|
||||||
@SerialName("progress")
|
|
||||||
val progress: Double,
|
|
||||||
@SerialName("episodeId")
|
|
||||||
val episodeId: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsItemResponse(
|
|
||||||
val id: String = "",
|
|
||||||
val media: AbsMedia? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsMedia(
|
|
||||||
val metadata: AbsMediaMetadata? = null,
|
|
||||||
@SerialName("audioFiles")
|
|
||||||
val audioFiles: List<AbsAudioFile> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsMediaMetadata(
|
|
||||||
val title: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsAudioFile(
|
|
||||||
val ino: String = "",
|
|
||||||
val metadata: AbsAudioFileMetadata? = null,
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AbsAudioFileMetadata(
|
|
||||||
val filename: String? = null,
|
|
||||||
val ext: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.AudioTrackResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
||||||
|
|
@ -19,20 +15,15 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesRe
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobsResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.Multipart
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Part
|
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
import retrofit2.http.POST
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
|
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
|
||||||
|
|
@ -42,54 +33,16 @@ interface BookshelfApiService {
|
||||||
|
|
||||||
// Health
|
// Health
|
||||||
@GET("health")
|
@GET("health")
|
||||||
suspend fun health(): Response<Unit>
|
suspend fun health(): Response<String>
|
||||||
|
|
||||||
// Libraries / books
|
// Libraries / books
|
||||||
@GET("api/v1/books/libraries")
|
@GET("api/v1/books/libraries")
|
||||||
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
suspend fun getLibraries(): Response<List<LibraryResponse>>
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/books/{itemId}/ebook")
|
|
||||||
suspend fun downloadEbook(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@GET("api/v1/books/{itemId}/tracks")
|
|
||||||
suspend fun getAudioTracks(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
): Response<List<AudioTrackResponse>>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/books/{itemId}/file/{fileId}")
|
|
||||||
suspend fun downloadAudioFile(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Path("fileId") fileId: String,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@GET("api/v1/books/{itemId}")
|
@GET("api/v1/books/{itemId}")
|
||||||
suspend fun getBook(
|
suspend fun getBook(
|
||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): Response<ResponseBody>
|
): Response<String>
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@POST("api/v1/books/{itemId}/progress")
|
|
||||||
suspend fun updateReadingProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Body request: ProgressUpdateRequest,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@Streaming
|
|
||||||
@POST("api/v1/books/{itemId}/playback-progress")
|
|
||||||
suspend fun updatePlaybackProgress(
|
|
||||||
@Path("itemId") itemId: String,
|
|
||||||
@Body request: PlaybackProgressUpdateRequest,
|
|
||||||
): Response<ResponseBody>
|
|
||||||
|
|
||||||
@GET("api/v1/books/library/{libraryId}/items")
|
|
||||||
suspend fun getLibraryItems(
|
|
||||||
@Path("libraryId") libraryId: String,
|
|
||||||
): Response<LibraryItemsResponse>
|
|
||||||
|
|
||||||
@GET("api/v1/books/library/{libraryId}/search")
|
@GET("api/v1/books/library/{libraryId}/search")
|
||||||
suspend fun searchLibrary(
|
suspend fun searchLibrary(
|
||||||
|
|
@ -130,13 +83,6 @@ interface BookshelfApiService {
|
||||||
@Path("jobId") jobId: String,
|
@Path("jobId") jobId: String,
|
||||||
): Response<ResponseBody>
|
): Response<ResponseBody>
|
||||||
|
|
||||||
@Multipart
|
|
||||||
@POST("api/v1/upload/book")
|
|
||||||
suspend fun uploadBook(
|
|
||||||
@Part file: MultipartBody.Part,
|
|
||||||
@Part("book_type") bookType: RequestBody,
|
|
||||||
): Response<UploadBookResponse>
|
|
||||||
|
|
||||||
// Downloads / sources
|
// Downloads / sources
|
||||||
@POST("api/v1/download")
|
@POST("api/v1/download")
|
||||||
suspend fun startDownload(
|
suspend fun startDownload(
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ServerStatusMonitor
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) {
|
|
||||||
private val _isOnline = MutableStateFlow(false)
|
|
||||||
val isOnline = _isOnline.asStateFlow()
|
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
private var pingJob: Job? = null
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (pingJob != null) return
|
|
||||||
pingJob = scope.launch {
|
|
||||||
while (true) {
|
|
||||||
val online = checkHealth()
|
|
||||||
_isOnline.value = online
|
|
||||||
delay(30_000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
pingJob?.cancel()
|
|
||||||
pingJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkHealth(): Boolean {
|
|
||||||
val url = serverSettings.getBookshelfUrl() ?: return false
|
|
||||||
val client = clientFactory.provideClient(url) ?: return false
|
|
||||||
return runCatching {
|
|
||||||
val response = client.health()
|
|
||||||
response.isSuccessful
|
|
||||||
}.onFailure {
|
|
||||||
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
|
||||||
}.getOrDefault(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AudioTrackResponse(
|
|
||||||
@SerialName("file_id")
|
|
||||||
val fileId: String,
|
|
||||||
val title: String = "",
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
val size: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for updating the audio playback progress of a book on the bookshelf-api.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PlaybackProgressUpdateRequest(
|
|
||||||
val itemId: String,
|
|
||||||
val libraryId: String,
|
|
||||||
@SerialName("current_file")
|
|
||||||
val currentFile: String,
|
|
||||||
@SerialName("current_position")
|
|
||||||
val currentPosition: Long,
|
|
||||||
val duration: Long,
|
|
||||||
@SerialName("updated_at")
|
|
||||||
val updatedAt: Long,
|
|
||||||
)
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request body for updating the local reading progress of a book on the bookshelf-api.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ProgressUpdateRequest(
|
|
||||||
val itemId: String,
|
|
||||||
val libraryId: String,
|
|
||||||
val scrollIndex: Int,
|
|
||||||
val scrollOffset: Int,
|
|
||||||
val progress: Float,
|
|
||||||
@SerialName("last_chapter")
|
|
||||||
val lastChapter: String? = null,
|
|
||||||
@SerialName("updated_at")
|
|
||||||
val updatedAt: Long,
|
|
||||||
)
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class LibraryItemsResponse(
|
|
||||||
val library: LibraryResponse = LibraryResponse(""),
|
|
||||||
val items: List<UnifiedItemResponse> = emptyList(),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UnifiedItemResponse(
|
|
||||||
val id: String,
|
|
||||||
val title: String = "",
|
|
||||||
val authors: List<String> = emptyList(),
|
|
||||||
val author: String = "",
|
|
||||||
val type: String = "",
|
|
||||||
@SerialName("media_type")
|
|
||||||
val mediaType: String = "",
|
|
||||||
@SerialName("library_id")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("cover_url")
|
|
||||||
val coverUrl: String = "",
|
|
||||||
val duration: Double = 0.0,
|
|
||||||
val size: Long = 0L,
|
|
||||||
@SerialName("progress")
|
|
||||||
val progress: UnifiedItemProgressResponse? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UnifiedItemProgressResponse(
|
|
||||||
val reading: ReadingProgressResponse? = null,
|
|
||||||
val playback: PlaybackProgressResponse? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ReadingProgressResponse(
|
|
||||||
@SerialName("itemId")
|
|
||||||
val itemId: String = "",
|
|
||||||
@SerialName("libraryId")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("scrollIndex")
|
|
||||||
val scrollIndex: Int = 0,
|
|
||||||
@SerialName("scrollOffset")
|
|
||||||
val scrollOffset: Int = 0,
|
|
||||||
val progress: Float = 0f,
|
|
||||||
@SerialName("last_chapter")
|
|
||||||
val lastChapter: String? = null,
|
|
||||||
@SerialName("updatedAt")
|
|
||||||
val updatedAt: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PlaybackProgressResponse(
|
|
||||||
@SerialName("itemId")
|
|
||||||
val itemId: String = "",
|
|
||||||
@SerialName("libraryId")
|
|
||||||
val libraryId: String = "",
|
|
||||||
@SerialName("current_file")
|
|
||||||
val currentFile: String = "",
|
|
||||||
@SerialName("current_position")
|
|
||||||
val currentPosition: Long = 0L,
|
|
||||||
val duration: Long = 0L,
|
|
||||||
@SerialName("updatedAt")
|
|
||||||
val updatedAt: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UploadBookResponse(
|
|
||||||
val success: Boolean = false,
|
|
||||||
val filename: String = "",
|
|
||||||
val type: String = "",
|
|
||||||
val path: String = "",
|
|
||||||
@SerialName("error")
|
|
||||||
val errorMessage: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class AudiobookshelfRepositoryImpl
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) : AudiobookshelfRepository {
|
|
||||||
override suspend fun getAudioTracks(itemId: String): Result<List<AudioTrack>> {
|
|
||||||
return runCatching {
|
|
||||||
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
|
||||||
if (bookshelfUrl.isNullOrBlank()) {
|
|
||||||
return Result.failure(IllegalStateException("Bookshelf API URL is not configured"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val client = clientFactory.provideClient(bookshelfUrl)
|
|
||||||
?: return Result.failure(IllegalStateException("Failed to create Bookshelf API client"))
|
|
||||||
|
|
||||||
val response = client.getAudioTracks(itemId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to fetch tracks: ${response.code()}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
response.body().orEmpty()
|
|
||||||
.mapIndexed { index, track ->
|
|
||||||
AudioTrack(
|
|
||||||
fileId = track.fileId,
|
|
||||||
title = track.title.takeIf { it.isNotBlank() } ?: "Track ${index + 1}",
|
|
||||||
durationMs = (track.duration * 1000).toLong(),
|
|
||||||
order = index,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.sortedBy { it.order }
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("AudiobookshelfRepo", "getAudioTracks failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -92,10 +92,4 @@ class BookRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -7,26 +7,22 @@
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
package org.dueattendant149.bookshelf.data.repository
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.util.Log
|
import android.webkit.URLUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import okhttp3.ResponseBody
|
|
||||||
import org.dueattendant149.bookshelf.data.local.dto.AudioFileEntity
|
|
||||||
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
|
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
|
||||||
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
|
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
|
||||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||||
import retrofit2.Response
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -38,275 +34,156 @@ class CacheRepositoryImpl
|
||||||
private val application: Application,
|
private val application: Application,
|
||||||
private val database: BookDatabase,
|
private val database: BookDatabase,
|
||||||
private val bookMapper: BookMapper,
|
private val bookMapper: BookMapper,
|
||||||
private val apiFactory: BookshelfApiClientFactory,
|
private val apiFactory: AudiobookshelfApiClientFactory,
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
private val audiobookshelfRepository: AudiobookshelfRepository,
|
|
||||||
) : CacheRepository {
|
) : CacheRepository {
|
||||||
|
|
||||||
private val cachedFileDao by lazy { database.cachedFileDao }
|
private val cachedFileDao by lazy { database.cachedFileDao }
|
||||||
private val audioFileDao by lazy { database.audioFileDao }
|
|
||||||
|
|
||||||
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
|
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
require(book.hasEbook) { "Book has no ebook." }
|
||||||
|
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
||||||
|
|
||||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||||
val localBookId = existing?.id ?: insertLocalBook(book)
|
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
||||||
|
|
||||||
if (book.hasEbook) {
|
if (existing != null &&
|
||||||
cacheEbook(book, localBookId)
|
cachedEbook?.localPath != null &&
|
||||||
}
|
File(cachedEbook.localPath).exists()
|
||||||
|
) {
|
||||||
if (book.hasAudio) {
|
if (existing.filePath != cachedEbook.localPath) {
|
||||||
cacheAudiobook(book, localBookId)
|
database.bookDao.updateBook(
|
||||||
}
|
bookMapper.toBookEntity(
|
||||||
|
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
|
||||||
localBookId
|
)
|
||||||
}
|
)
|
||||||
}.onFailure {
|
}
|
||||||
Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it)
|
return@withContext existing.id
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun insertLocalBook(book: Book): Int {
|
|
||||||
database.bookDao.insertBook(bookMapper.toBookEntity(book))
|
|
||||||
return database.bookDao.findBookByRemoteId(book.remoteId)?.id
|
|
||||||
?: throw IllegalStateException("Could not insert book.")
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun cacheEbook(book: Book, bookId: Int) {
|
|
||||||
val existingEbook = database.ebookFileDao.getByBookId(bookId)
|
|
||||||
if (existingEbook?.localPath != null && File(existingEbook.localPath).exists()) {
|
|
||||||
updateBookFilePath(book, existingEbook.localPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
|
||||||
?: throw IllegalStateException("Bookshelf API URL is not configured.")
|
|
||||||
val client = apiFactory.provideClient(bookshelfUrl)
|
|
||||||
?: throw IllegalStateException("Could not create Bookshelf API client.")
|
|
||||||
|
|
||||||
val response = client.downloadEbook(book.remoteId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw IllegalStateException("Ebook download failed: ${response.code()}")
|
|
||||||
}
|
|
||||||
val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.")
|
|
||||||
|
|
||||||
val file = saveFileToDir(
|
|
||||||
body,
|
|
||||||
dirName = "ebooks",
|
|
||||||
fileName = guessFileName(response, book.remoteId)
|
|
||||||
)
|
|
||||||
|
|
||||||
database.ebookFileDao.insert(
|
|
||||||
EbookFileEntity(
|
|
||||||
bookId = bookId,
|
|
||||||
fileId = book.remoteId,
|
|
||||||
format = file.extension,
|
|
||||||
localPath = file.absolutePath,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
cachedFileDao.insert(
|
|
||||||
CachedFileEntity(
|
|
||||||
bookId = bookId,
|
|
||||||
type = "ebook",
|
|
||||||
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook",
|
|
||||||
localPath = file.absolutePath,
|
|
||||||
status = "completed",
|
|
||||||
progress = 1f,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
updateBookFilePath(book, file.absolutePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun cacheAudiobook(book: Book, bookId: Int) {
|
|
||||||
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
|
||||||
val tracks = tracksResult.getOrThrow()
|
|
||||||
if (tracks.isEmpty()) {
|
|
||||||
Log.w("CacheRepository", "No audio tracks for ${book.remoteId}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
|
||||||
?: throw IllegalStateException("Bookshelf API URL is not configured.")
|
|
||||||
val client = apiFactory.provideClient(bookshelfUrl)
|
|
||||||
?: throw IllegalStateException("Could not create Bookshelf API client.")
|
|
||||||
|
|
||||||
// Persist track metadata if missing.
|
|
||||||
val existingTracks = audioFileDao.getByBookId(bookId)
|
|
||||||
if (existingTracks.isEmpty()) {
|
|
||||||
audioFileDao.insertAll(
|
|
||||||
tracks.mapIndexed { index, track ->
|
|
||||||
AudioFileEntity(
|
|
||||||
bookId = bookId,
|
|
||||||
fileId = track.fileId,
|
|
||||||
remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
|
|
||||||
duration = track.durationMs / 1000.0,
|
|
||||||
order = index,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks.forEachIndexed { index, track ->
|
val url = serverSettings.getAbsUrl()
|
||||||
val cached = audioFileDao.getByBookId(bookId)
|
val token = serverSettings.getAbsToken()
|
||||||
.firstOrNull { it.fileId == track.fileId }
|
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
||||||
if (cached?.localPath != null && File(cached.localPath).exists()) {
|
throw IllegalStateException("Audiobookshelf server is not configured.")
|
||||||
updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath)
|
}
|
||||||
return@forEachIndexed
|
|
||||||
}
|
|
||||||
|
|
||||||
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading")
|
val client = apiFactory.provideClient(url, token)
|
||||||
val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}"
|
?: throw IllegalStateException("Could not create Audiobookshelf client.")
|
||||||
val response = client.downloadAudioFile(book.remoteId, track.fileId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
|
|
||||||
throw IllegalStateException("Audio track $index download failed: ${response.code()}")
|
|
||||||
}
|
|
||||||
val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
|
|
||||||
val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
|
|
||||||
|
|
||||||
audioFileDao.update(
|
val response = client.downloadBook(book.remoteId)
|
||||||
(cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId))
|
if (!response.isSuccessful) {
|
||||||
.copy(localPath = file.absolutePath)
|
throw IllegalStateException("Download failed: ${response.code()}")
|
||||||
)
|
}
|
||||||
|
|
||||||
val overallProgress = (index + 1).toFloat() / tracks.size
|
val body = response.body()
|
||||||
updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed")
|
?: throw IllegalStateException("Download response body is empty.")
|
||||||
cachedFileDao.insert(
|
|
||||||
CachedFileEntity(
|
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
|
||||||
bookId = bookId,
|
val fileName = guessFileName(response, book.remoteId)
|
||||||
type = "audio_${track.fileId}",
|
val file = File(ebooksDir, fileName)
|
||||||
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
|
|
||||||
localPath = file.absolutePath,
|
body.byteStream().use { input ->
|
||||||
status = "completed",
|
file.outputStream().use { output ->
|
||||||
progress = overallProgress,
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val localBook = if (existing != null) {
|
||||||
|
book.copy(
|
||||||
|
id = existing.id,
|
||||||
|
filePath = file.absolutePath,
|
||||||
|
scrollIndex = existing.scrollIndex,
|
||||||
|
scrollOffset = existing.scrollOffset,
|
||||||
|
progress = existing.progress,
|
||||||
|
categories = existing.categories,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
book.copy(filePath = file.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
|
||||||
|
|
||||||
|
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||||
|
?: throw IllegalStateException("Could not insert or update book.")
|
||||||
|
|
||||||
|
database.ebookFileDao.insert(
|
||||||
|
EbookFileEntity(
|
||||||
|
bookId = inserted.id,
|
||||||
|
fileId = book.remoteId,
|
||||||
|
format = file.extension,
|
||||||
|
localPath = file.absolutePath,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateAudioTrackProgress(
|
cachedFileDao.insert(
|
||||||
bookId: Int,
|
CachedFileEntity(
|
||||||
fileId: String,
|
bookId = inserted.id,
|
||||||
progress: Float,
|
type = "ebook",
|
||||||
localPath: String?,
|
remoteUrl = "$url/api/items/${book.remoteId}/download",
|
||||||
status: String = "completed",
|
localPath = file.absolutePath,
|
||||||
) {
|
status = "completed",
|
||||||
val type = "audio_$fileId"
|
progress = 1f,
|
||||||
val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull()
|
)
|
||||||
val entity = CachedFileEntity(
|
|
||||||
id = existing?.id ?: 0,
|
|
||||||
bookId = bookId,
|
|
||||||
type = type,
|
|
||||||
remoteUrl = existing?.remoteUrl ?: "",
|
|
||||||
localPath = localPath ?: existing?.localPath,
|
|
||||||
status = status,
|
|
||||||
progress = progress,
|
|
||||||
)
|
|
||||||
if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateBookFilePath(book: Book, path: String) {
|
|
||||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
|
|
||||||
if (existing.filePath != path) {
|
|
||||||
database.bookDao.updateBook(
|
|
||||||
bookMapper.toBookEntity(
|
|
||||||
bookMapper.toBook(existing).copy(filePath = path)
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File {
|
inserted.id
|
||||||
val dir = File(application.filesDir, dirName).apply { mkdirs() }
|
}
|
||||||
val file = File(dir, fileName)
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
file.outputStream().use { output -> input.copyTo(output) }
|
|
||||||
}
|
}
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun guessAudioExtension(fileNameOrTitle: String): String {
|
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
||||||
val name = fileNameOrTitle.lowercase()
|
withContext(Dispatchers.IO) {
|
||||||
return when {
|
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||||
name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b"
|
?: throw IllegalStateException("Book not found")
|
||||||
name.endsWith(".mp3") -> "mp3"
|
|
||||||
name.endsWith(".flac") -> "flac"
|
|
||||||
name.endsWith(".ogg") -> "ogg"
|
|
||||||
name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b"
|
|
||||||
else -> "mp3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
|
||||||
if (book != null) {
|
|
||||||
val ebook = database.ebookFileDao.getByBookId(book.id)
|
val ebook = database.ebookFileDao.getByBookId(book.id)
|
||||||
val cachedFiles = cachedFileDao.getByBookId(book.id)
|
val cachedFiles = cachedFileDao.getByBookId(book.id)
|
||||||
val audioFiles = audioFileDao.getByBookId(book.id)
|
|
||||||
|
|
||||||
ebook?.localPath?.let { File(it).delete() }
|
ebook?.localPath?.let { File(it).delete() }
|
||||||
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
||||||
audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
|
||||||
|
|
||||||
database.ebookFileDao.deleteByBookId(book.id)
|
database.ebookFileDao.deleteByBookId(book.id)
|
||||||
cachedFileDao.deleteByBookId(book.id)
|
cachedFileDao.deleteByBookId(book.id)
|
||||||
audioFileDao.deleteByBookId(book.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure {
|
|
||||||
Log.e("CacheRepository", "deleteCache failed for $remoteId", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
|
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
|
||||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||||
if (book == null) {
|
if (book == null) {
|
||||||
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
|
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
|
||||||
return@flow
|
return@flow
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedFileDao.observeByBookId(book.id).collect { entities ->
|
||||||
|
val files = entities.map {
|
||||||
|
CachedFile(
|
||||||
|
type = it.type,
|
||||||
|
status = it.status,
|
||||||
|
progress = it.progress,
|
||||||
|
localPath = it.localPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val state = when {
|
||||||
|
entities.isEmpty() -> CacheState.NONE
|
||||||
|
entities.all { it.status == "completed" } -> CacheState.COMPLETED
|
||||||
|
entities.any { it.status == "failed" } -> CacheState.FAILED
|
||||||
|
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
||||||
|
entities.any { it.status == "pending" } -> CacheState.PENDING
|
||||||
|
else -> CacheState.NONE
|
||||||
|
}
|
||||||
|
val progress = if (entities.isEmpty()) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
entities.map { it.progress }.average().toFloat()
|
||||||
|
}
|
||||||
|
emit(CacheStatus(remoteId, state, progress, files))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cachedFileDao.observeByBookId(book.id).collect { entities ->
|
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
|
||||||
val files = entities.map {
|
val contentDisposition = response.headers()["Content-Disposition"]
|
||||||
CachedFile(
|
val mimeType = response.body()?.contentType()?.toString()
|
||||||
type = it.type,
|
val url = response.raw().request.url.toString()
|
||||||
status = it.status,
|
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||||
progress = it.progress,
|
return if (name.contains(".")) name else "$remoteId.epub"
|
||||||
localPath = it.localPath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val state = when {
|
|
||||||
entities.isEmpty() -> CacheState.NONE
|
|
||||||
entities.all { it.status == "completed" } -> CacheState.COMPLETED
|
|
||||||
entities.any { it.status == "failed" } -> CacheState.FAILED
|
|
||||||
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
|
||||||
entities.any { it.status == "pending" } -> CacheState.PENDING
|
|
||||||
else -> CacheState.NONE
|
|
||||||
}
|
|
||||||
val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat()
|
|
||||||
emit(CacheStatus(remoteId, state, progress, files))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun guessFileName(response: Response<ResponseBody>, remoteId: String): String {
|
|
||||||
val contentDisposition = response.headers()["Content-Disposition"]
|
|
||||||
val mimeType = response.body()?.contentType()?.toString()
|
|
||||||
val url = response.raw().request.url.toString()
|
|
||||||
val name = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType)
|
|
||||||
return if (name.contains(".")) name else "$remoteId.epub"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Public accessor used by the player to read already-cached tracks. */
|
|
||||||
suspend fun getCachedTracks(bookId: Int): List<AudioFileEntity> =
|
|
||||||
audioFileDao.getCachedByBookId(bookId)
|
|
||||||
|
|
||||||
/** Public accessor used by the player to resolve a cached local file for streaming. */
|
|
||||||
suspend fun findCachedAudioFile(bookId: Int, fileId: String): File? {
|
|
||||||
return audioFileDao.getByBookId(bookId)
|
|
||||||
.firstOrNull { it.fileId == fileId }
|
|
||||||
?.localPath
|
|
||||||
?.let { File(it).takeIf { f -> f.exists() } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
|
||||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class ProgressRepositoryImpl
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val database: BookDatabase,
|
|
||||||
private val bookMapper: BookMapper,
|
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) : ProgressRepository {
|
|
||||||
override suspend fun syncReadingProgress(
|
|
||||||
book: Book,
|
|
||||||
lastChapter: String?,
|
|
||||||
): Result<Unit> = runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val client = client() ?: return@withContext
|
|
||||||
val request =
|
|
||||||
ProgressUpdateRequest(
|
|
||||||
itemId = book.remoteId,
|
|
||||||
libraryId = book.libraryId,
|
|
||||||
scrollIndex = book.scrollIndex,
|
|
||||||
scrollOffset = book.scrollOffset,
|
|
||||||
progress = book.progress,
|
|
||||||
lastChapter = lastChapter,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val response = client.updateReadingProgress(book.remoteId, request)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw RuntimeException("Failed to sync reading progress: ${response.code()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLastSyncedAt(book)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "syncReadingProgress failed for ${book.remoteId}", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun syncPlaybackProgress(
|
|
||||||
book: Book,
|
|
||||||
currentFile: String,
|
|
||||||
position: Long,
|
|
||||||
duration: Long,
|
|
||||||
): Result<Unit> = runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val client = client() ?: return@withContext
|
|
||||||
val request =
|
|
||||||
PlaybackProgressUpdateRequest(
|
|
||||||
itemId = book.remoteId,
|
|
||||||
libraryId = book.libraryId,
|
|
||||||
currentFile = currentFile,
|
|
||||||
currentPosition = position,
|
|
||||||
duration = duration,
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
|
|
||||||
val response = client.updatePlaybackProgress(book.remoteId, request)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw RuntimeException("Failed to sync playback progress: ${response.code()}")
|
|
||||||
}
|
|
||||||
|
|
||||||
updateLastSyncedAt(book)
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "syncPlaybackProgress failed for ${book.remoteId}", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
|
|
||||||
}
|
|
||||||
}.onFailure {
|
|
||||||
Log.e(TAG, "getBooksWithRemoteId failed", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun client() =
|
|
||||||
serverSettings.getBookshelfUrl()?.let { url ->
|
|
||||||
clientFactory.provideClient(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun updateLastSyncedAt(book: Book) {
|
|
||||||
val entity = bookMapper.toBookEntity(book.copy(lastSyncedAt = System.currentTimeMillis()))
|
|
||||||
database.bookDao.updateBook(entity)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "ProgressRepository"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,21 +7,9 @@
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
package org.dueattendant149.bookshelf.data.repository
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
|
||||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||||
|
|
@ -35,8 +23,6 @@ class RemoteLibraryRepositoryImpl
|
||||||
constructor(
|
constructor(
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
private val clientFactory: BookshelfApiClientFactory,
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
private val database: BookDatabase,
|
|
||||||
private val bookMapper: BookMapper,
|
|
||||||
) : RemoteLibraryRepository {
|
) : RemoteLibraryRepository {
|
||||||
override suspend fun getLibraries(): Result<List<RemoteLibrary>> {
|
override suspend fun getLibraries(): Result<List<RemoteLibrary>> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
|
|
@ -52,42 +38,7 @@ class RemoteLibraryRepositoryImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getBooks(libraryId: String): Result<List<Book>> {
|
override suspend fun getBooks(libraryId: String): Result<List<Book>> {
|
||||||
return runCatching {
|
return searchLibrary(libraryId, "")
|
||||||
val client = client() ?: return Result.success(emptyList())
|
|
||||||
val response = client.getLibraryItems(libraryId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to load library items: ${response.code()}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val body = response.body() ?: LibraryItemsResponse()
|
|
||||||
val items = body.items.map { it.toBook() }
|
|
||||||
|
|
||||||
// Upsert each item into the local DB keyed by remoteId,
|
|
||||||
// preserving local file path/progress if the book already exists.
|
|
||||||
items.forEach { book ->
|
|
||||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
|
||||||
val toStore = if (existing != null) {
|
|
||||||
val existingBook = bookMapper.toBook(existing)
|
|
||||||
book.copy(
|
|
||||||
id = existingBook.id,
|
|
||||||
filePath = existingBook.filePath,
|
|
||||||
scrollIndex = existingBook.scrollIndex,
|
|
||||||
scrollOffset = existingBook.scrollOffset,
|
|
||||||
progress = existingBook.progress,
|
|
||||||
categories = existingBook.categories,
|
|
||||||
lastOpened = existingBook.lastOpened,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
book
|
|
||||||
}
|
|
||||||
database.bookDao.insertBook(bookMapper.toBookEntity(toStore))
|
|
||||||
}
|
|
||||||
|
|
||||||
Result.success(items)
|
|
||||||
}.getOrElse {
|
|
||||||
Log.e("RemoteLibraryRepo", "getBooks failed", it)
|
|
||||||
Result.failure(it)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchLibrary(
|
override suspend fun searchLibrary(
|
||||||
|
|
@ -106,59 +57,6 @@ class RemoteLibraryRepositoryImpl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun search(request: SearchRequest): Result<SearchResponse> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.search(request)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Search failed: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body() ?: SearchResponse()
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteLibraryRepo", "search failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.startDownload(request)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Download failed: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body() ?: DownloadResponse()
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteLibraryRepo", "startDownload failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun listDownloads(): Result<DownloadsListResponse> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.listDownloads()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("List downloads failed: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body() ?: DownloadsListResponse()
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteLibraryRepo", "listDownloads failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun uploadBook(
|
|
||||||
file: MultipartBody.Part,
|
|
||||||
bookType: RequestBody,
|
|
||||||
): Result<UploadBookResponse> = runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.uploadBook(file, bookType)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Upload failed: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body() ?: UploadBookResponse(success = false, errorMessage = "Empty response")
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteLibraryRepo", "uploadBook failed", it)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
||||||
|
|
||||||
private fun LibraryResponse.toDomain() =
|
private fun LibraryResponse.toDomain() =
|
||||||
|
|
@ -169,54 +67,7 @@ class RemoteLibraryRepositoryImpl
|
||||||
itemCount = itemCount,
|
itemCount = itemCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun UnifiedItemResponse.toBook(): Book {
|
private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
|
||||||
val resolvedCoverUrl = resolveCoverUrl(coverUrl)
|
|
||||||
return Book(
|
|
||||||
id = 0,
|
|
||||||
title = title,
|
|
||||||
author = UIText.StringValue(
|
|
||||||
author.takeIf { it.isNotBlank() }
|
|
||||||
?: authors.filter { it.isNotBlank() }.joinToString(", ")
|
|
||||||
.takeIf { it.isNotBlank() }
|
|
||||||
?: ""
|
|
||||||
),
|
|
||||||
description = null,
|
|
||||||
filePath = "",
|
|
||||||
coverImage = null,
|
|
||||||
scrollIndex = progress?.reading?.scrollIndex ?: 0,
|
|
||||||
scrollOffset = progress?.reading?.scrollOffset ?: 0,
|
|
||||||
progress = progress?.reading?.progress ?: 0f,
|
|
||||||
lastOpened = null,
|
|
||||||
categories = emptyList(),
|
|
||||||
remoteId = id,
|
|
||||||
libraryId = libraryId,
|
|
||||||
mediaType = mediaType,
|
|
||||||
itemType = type,
|
|
||||||
hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid",
|
|
||||||
hasEbook = type == "ebook" || type == "hybrid",
|
|
||||||
audioDuration = progress?.playback?.duration ?: duration.toLong(),
|
|
||||||
audioCurrentFile = progress?.playback?.currentFile ?: "",
|
|
||||||
audioCurrentPosition = progress?.playback?.currentPosition ?: 0L,
|
|
||||||
coverUrl = resolvedCoverUrl,
|
|
||||||
lastSyncedAt = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun resolveCoverUrl(coverUrl: String): String {
|
|
||||||
if (coverUrl.isBlank()) return ""
|
|
||||||
val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl
|
|
||||||
val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/')
|
|
||||||
return when {
|
|
||||||
coverUrl.startsWith("http://", ignoreCase = true) ||
|
|
||||||
coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl
|
|
||||||
|
|
||||||
coverUrl.startsWith("/") -> "$base$coverUrl"
|
|
||||||
|
|
||||||
else -> "$base/$coverUrl"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
|
|
||||||
Book(
|
Book(
|
||||||
id = 0,
|
id = 0,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -232,7 +83,6 @@ class RemoteLibraryRepositoryImpl
|
||||||
remoteId = id,
|
remoteId = id,
|
||||||
libraryId = libraryId,
|
libraryId = libraryId,
|
||||||
mediaType = mediaType,
|
mediaType = mediaType,
|
||||||
itemType = mediaType,
|
|
||||||
hasAudio = mediaType == "audiobook" || mediaType == "podcast",
|
hasAudio = mediaType == "audiobook" || mediaType == "podcast",
|
||||||
hasEbook = mediaType == "book",
|
hasEbook = mediaType == "book",
|
||||||
audioDuration = duration.toLong(),
|
audioDuration = duration.toLong(),
|
||||||
|
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEngineResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoiceResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class RemoteTtsRepositoryImpl
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val clientFactory: BookshelfApiClientFactory,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
) : RemoteTtsRepository {
|
|
||||||
override suspend fun fetchEngines(): Result<List<TtsEngine>> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.success(emptyList())
|
|
||||||
val response = client.getTtsEngines()
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to fetch TTS engines: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body()?.engines.orEmpty().map { it.toDomain() }
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteTtsRepo", "fetchEngines failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun fetchVoices(engine: String?): Result<List<TtsVoice>> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.success(emptyList())
|
|
||||||
val response = client.getTtsVoices(engine)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to fetch TTS voices: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body()?.voices.orEmpty().map { it.toDomain() }
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteTtsRepo", "fetchVoices failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun createJob(
|
|
||||||
bookId: String,
|
|
||||||
engine: String,
|
|
||||||
voiceId: String,
|
|
||||||
speed: Double,
|
|
||||||
): Result<TtsJob> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.createTtsJob(
|
|
||||||
TtsCreateRequest(
|
|
||||||
bookId = bookId,
|
|
||||||
engine = engine,
|
|
||||||
voiceId = voiceId,
|
|
||||||
speed = speed,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to create TTS job: ${response.code()}"))
|
|
||||||
}
|
|
||||||
val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response"))
|
|
||||||
body.toDomain()
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteTtsRepo", "createJob failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getJob(jobId: String): Result<TtsJob> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.getTtsJobStatus(jobId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to fetch TTS job: ${response.code()}"))
|
|
||||||
}
|
|
||||||
val body = response.body() ?: return Result.failure(RuntimeException("Empty TTS job response"))
|
|
||||||
body.toDomain()
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteTtsRepo", "getJob failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun downloadAudio(jobId: String): Result<ByteArray> {
|
|
||||||
return runCatching {
|
|
||||||
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
|
||||||
val response = client.downloadTtsAudio(jobId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
return Result.failure(RuntimeException("Failed to download TTS audio: ${response.code()}"))
|
|
||||||
}
|
|
||||||
response.body()?.bytes() ?: return Result.failure(RuntimeException("Empty TTS audio response"))
|
|
||||||
}.onFailure {
|
|
||||||
Log.e("RemoteTtsRepo", "downloadAudio failed", it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
|
||||||
|
|
||||||
private fun TtsEngineResponse.toDomain() =
|
|
||||||
TtsEngine(
|
|
||||||
id = id,
|
|
||||||
name = name,
|
|
||||||
supportsStreaming = capabilities.supportsStreaming,
|
|
||||||
supportsCloning = capabilities.supportsCloning,
|
|
||||||
maxTextLength = capabilities.maxTextLength,
|
|
||||||
needsNetwork = capabilities.needsNetwork,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun TtsVoiceResponse.toDomain() =
|
|
||||||
TtsVoice(
|
|
||||||
id = id,
|
|
||||||
name = name,
|
|
||||||
language = language,
|
|
||||||
engine = engine,
|
|
||||||
gender = gender,
|
|
||||||
quality = quality,
|
|
||||||
requiresReference = requiresReference,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun TtsJobResponse.toDomain() =
|
|
||||||
TtsJob(
|
|
||||||
jobId = jobId,
|
|
||||||
bookId = bookId,
|
|
||||||
title = title,
|
|
||||||
author = author,
|
|
||||||
engine = engine,
|
|
||||||
voiceId = voiceId,
|
|
||||||
speed = speed,
|
|
||||||
status = status,
|
|
||||||
progress = progress,
|
|
||||||
currentChapter = currentChapter,
|
|
||||||
totalChapters = totalChapters,
|
|
||||||
completedChapters = completedChapters,
|
|
||||||
outputPath = outputPath,
|
|
||||||
error = error,
|
|
||||||
createdAt = createdAt,
|
|
||||||
startedAt = startedAt,
|
|
||||||
completedAt = completedAt,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.data.settings
|
||||||
|
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import org.dueattendant149.bookshelf.data.local.data_store.DataStore
|
import org.dueattendant149.bookshelf.data.local.data_store.DataStore
|
||||||
import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
|
|
||||||
import org.dueattendant149.bookshelf.domain.util.isValidUri
|
import org.dueattendant149.bookshelf.domain.util.isValidUri
|
||||||
import org.dueattendant149.bookshelf.domain.util.normalizeUri
|
import org.dueattendant149.bookshelf.domain.util.normalizeUri
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -48,12 +47,11 @@ class ServerSettings
|
||||||
* or null when all credentials are present.
|
* or null when all credentials are present.
|
||||||
*/
|
*/
|
||||||
suspend fun getMissingCredentialsError(): String? {
|
suspend fun getMissingCredentialsError(): String? {
|
||||||
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)?.trim()
|
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)
|
||||||
if (bookshelfUrl.isNullOrBlank()) {
|
if (bookshelfUrl.isNullOrBlank()) {
|
||||||
return "Bookshelf API URL is missing"
|
return "Bookshelf API URL is missing"
|
||||||
}
|
}
|
||||||
val normalizedBookshelfUrl = bookshelfUrl.ensureUriScheme()
|
if (!bookshelfUrl.isValidUri()) {
|
||||||
if (!normalizedBookshelfUrl.isValidUri()) {
|
|
||||||
return "Bookshelf API URL is invalid: $bookshelfUrl"
|
return "Bookshelf API URL is invalid: $bookshelfUrl"
|
||||||
}
|
}
|
||||||
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
|
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
|
||||||
|
|
@ -69,8 +67,8 @@ class ServerSettings
|
||||||
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
|
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
|
||||||
return when {
|
return when {
|
||||||
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
|
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
|
||||||
!url.ensureUriScheme().isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
|
!url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
|
||||||
else -> Result.success(url.ensureUriScheme().normalizeUri())
|
else -> Result.success(url.normalizeUri())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,8 +80,6 @@ class ServerSettings
|
||||||
|
|
||||||
private fun String.normalizeIfValid(): String? {
|
private fun String.normalizeIfValid(): String? {
|
||||||
val trimmed = trim()
|
val trimmed = trim()
|
||||||
if (trimmed.isBlank()) return null
|
return if (trimmed.isValidUri()) trimmed.normalizeUri() else null
|
||||||
val withScheme = trimmed.ensureUriScheme()
|
|
||||||
return if (withScheme.isValidUri()) withScheme.normalizeUri() else null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -180,26 +180,6 @@ class SettingsManager @Inject constructor(
|
||||||
val perceptionExpanderThickness = setting<Int, Int>(
|
val perceptionExpanderThickness = setting<Int, Int>(
|
||||||
key = intPreferencesKey("perception_expander_thickness"), default = 4
|
key = intPreferencesKey("perception_expander_thickness"), default = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
// RSVP (speed reading)
|
|
||||||
val rsvpWpm = setting<Int, Int>(
|
|
||||||
key = intPreferencesKey("rsvp_wpm"), default = 350
|
|
||||||
)
|
|
||||||
val rsvpFontSize = setting<Int, Int>(
|
|
||||||
key = intPreferencesKey("rsvp_font_size"), default = 56
|
|
||||||
)
|
|
||||||
val rsvpPauseOnParagraph = setting<Boolean, Boolean>(
|
|
||||||
key = booleanPreferencesKey("rsvp_pause_paragraph"), default = true
|
|
||||||
)
|
|
||||||
val rsvpPauseOnChapter = setting<Boolean, Boolean>(
|
|
||||||
key = booleanPreferencesKey("rsvp_pause_chapter"), default = true
|
|
||||||
)
|
|
||||||
val rsvpPauseOnLongWords = setting<Boolean, Boolean>(
|
|
||||||
key = booleanPreferencesKey("rsvp_pause_long_words"), default = true
|
|
||||||
)
|
|
||||||
val rsvpShowFocusGuide = setting<Boolean, Boolean>(
|
|
||||||
key = booleanPreferencesKey("rsvp_show_focus_guide"), default = true
|
|
||||||
)
|
|
||||||
val horizontalLimiter = setting<Boolean, Boolean>(
|
val horizontalLimiter = setting<Boolean, Boolean>(
|
||||||
key = booleanPreferencesKey("horizontal_limiter"), default = false
|
key = booleanPreferencesKey("horizontal_limiter"), default = false
|
||||||
)
|
)
|
||||||
|
|
@ -231,9 +211,6 @@ class SettingsManager @Inject constructor(
|
||||||
key = doublePreferencesKey("screen_brightness"), default = 0.5f,
|
key = doublePreferencesKey("screen_brightness"), default = 0.5f,
|
||||||
serialize = { it.toDouble() }, deserialize = { it.toFloat() }
|
serialize = { it.toDouble() }, deserialize = { it.toFloat() }
|
||||||
)
|
)
|
||||||
val readerPagination = setting<Boolean, Boolean>(
|
|
||||||
key = booleanPreferencesKey("reader_pagination"), default = true
|
|
||||||
)
|
|
||||||
val horizontalGesture = setting<ReaderHorizontalGesture, String>(
|
val horizontalGesture = setting<ReaderHorizontalGesture, String>(
|
||||||
key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF,
|
key = stringPreferencesKey("horizontal_gesture"), default = ReaderHorizontalGesture.OFF,
|
||||||
serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) }
|
serialize = { it.name }, deserialize = { ReaderHorizontalGesture.valueOf(it) }
|
||||||
|
|
|
||||||
|
|
@ -96,14 +96,13 @@ class CacheDownloadWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAudio) {
|
if (hasAudio) {
|
||||||
downloadAudioTracks(
|
downloadFile(
|
||||||
database = database,
|
database = database,
|
||||||
bookId = bookId,
|
bookId = bookId,
|
||||||
absUrl = absUrl,
|
type = "audio",
|
||||||
remoteId = remoteId,
|
remoteUrl = "$absUrl/api/items/$remoteId/download",
|
||||||
bookDir = bookDir,
|
localFile = File(bookDir, "audio"),
|
||||||
client = client,
|
) { client.downloadBook(remoteId) }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Result.success()
|
Result.success()
|
||||||
|
|
@ -113,65 +112,6 @@ class CacheDownloadWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun downloadAudioTracks(
|
|
||||||
database: BookDatabase,
|
|
||||||
bookId: Int,
|
|
||||||
absUrl: String,
|
|
||||||
remoteId: String,
|
|
||||||
bookDir: File,
|
|
||||||
client: org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiService,
|
|
||||||
) {
|
|
||||||
val audioDir = File(bookDir, "audio").apply { mkdirs() }
|
|
||||||
|
|
||||||
val itemResponse = client.getItem(remoteId)
|
|
||||||
if (!itemResponse.isSuccessful) {
|
|
||||||
throw IOException("Failed to fetch item for audio tracks: ${itemResponse.code()}")
|
|
||||||
}
|
|
||||||
val item = itemResponse.body() ?: throw IOException("Empty item response")
|
|
||||||
val audioFiles = item.media?.audioFiles.orEmpty()
|
|
||||||
|
|
||||||
if (audioFiles.isEmpty()) {
|
|
||||||
Log.w(TAG, "No audio files found for book $bookId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val totalTracks = audioFiles.size
|
|
||||||
audioFiles.forEachIndexed { index, file ->
|
|
||||||
val fileId = file.ino
|
|
||||||
val trackFile = File(audioDir, "${index}_${fileId}")
|
|
||||||
val record = getOrCreateRecord(
|
|
||||||
database,
|
|
||||||
bookId,
|
|
||||||
"audio_${index}",
|
|
||||||
"$absUrl/api/items/$remoteId/file/$fileId",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) {
|
|
||||||
return@forEachIndexed
|
|
||||||
}
|
|
||||||
|
|
||||||
database.cachedFileDao.update(record.copy(status = "downloading", progress = index.toFloat() / totalTracks))
|
|
||||||
|
|
||||||
val response = client.downloadAudioFile(remoteId, fileId)
|
|
||||||
if (!response.isSuccessful) {
|
|
||||||
throw IOException("Audio track $index download failed: ${response.code()}")
|
|
||||||
}
|
|
||||||
val body = response.body() ?: throw IOException("Empty audio response body")
|
|
||||||
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
trackFile.outputStream().use { output -> input.copyTo(output) }
|
|
||||||
}
|
|
||||||
|
|
||||||
database.cachedFileDao.update(
|
|
||||||
record.copy(
|
|
||||||
status = "completed",
|
|
||||||
progress = (index + 1).toFloat() / totalTracks,
|
|
||||||
localPath = trackFile.absolutePath,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun downloadFile(
|
private suspend fun downloadFile(
|
||||||
database: BookDatabase,
|
database: BookDatabase,
|
||||||
bookId: Int,
|
bookId: Int,
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.worker
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.work.Constraints
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
|
||||||
import androidx.work.NetworkType
|
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import dagger.hilt.EntryPoint
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.EntryPointAccessors
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Periodic worker that syncs reading and playback progress for all books
|
|
||||||
* that have a remote (bookshelf-api) identifier.
|
|
||||||
*/
|
|
||||||
class ProgressSyncWorker(
|
|
||||||
context: Context,
|
|
||||||
params: WorkerParameters,
|
|
||||||
) : CoroutineWorker(context, params) {
|
|
||||||
companion object {
|
|
||||||
const val WORK_NAME = "progress-sync-worker"
|
|
||||||
|
|
||||||
fun schedule(workManager: WorkManager) {
|
|
||||||
val request =
|
|
||||||
PeriodicWorkRequestBuilder<ProgressSyncWorker>(15, TimeUnit.MINUTES)
|
|
||||||
.setConstraints(
|
|
||||||
Constraints
|
|
||||||
.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
|
||||||
WORK_NAME,
|
|
||||||
ExistingPeriodicWorkPolicy.KEEP,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val entryPoint: ProgressSyncEntryPoint by lazy {
|
|
||||||
EntryPointAccessors.fromApplication(applicationContext, ProgressSyncEntryPoint::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
val syncReading = entryPoint.syncReadingProgressUseCase()
|
|
||||||
val syncPlayback = entryPoint.syncPlaybackProgressUseCase()
|
|
||||||
|
|
||||||
val books = entryPoint.progressRepository().getBooksWithRemoteId().getOrElse {
|
|
||||||
Log.e(TAG, "Failed to load books with remote IDs", it)
|
|
||||||
return Result.retry()
|
|
||||||
}
|
|
||||||
|
|
||||||
var failures = 0
|
|
||||||
books.forEach { book ->
|
|
||||||
syncReading(book, lastChapter = null).onFailure {
|
|
||||||
Log.w(TAG, "Reading progress sync failed for ${book.remoteId}", it)
|
|
||||||
failures++
|
|
||||||
}
|
|
||||||
|
|
||||||
if (book.hasAudio && book.audioDuration > 0L) {
|
|
||||||
syncPlayback(
|
|
||||||
book = book,
|
|
||||||
currentFile = book.audioCurrentFile,
|
|
||||||
position = book.audioCurrentPosition,
|
|
||||||
duration = book.audioDuration,
|
|
||||||
).onFailure {
|
|
||||||
Log.w(TAG, "Playback progress sync failed for ${book.remoteId}", it)
|
|
||||||
failures++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (failures == 0) {
|
|
||||||
Result.success()
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "$failures/${books.size} books failed to sync, retrying")
|
|
||||||
Result.retry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@EntryPoint
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
interface ProgressSyncEntryPoint {
|
|
||||||
fun syncReadingProgressUseCase(): SyncReadingProgressUseCase
|
|
||||||
fun syncPlaybackProgressUseCase(): SyncPlaybackProgressUseCase
|
|
||||||
fun progressRepository(): org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val TAG = "ProgressSyncWorker"
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.data.worker
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.hilt.work.HiltWorker
|
|
||||||
import androidx.work.CoroutineWorker
|
|
||||||
import androidx.work.Data
|
|
||||||
import androidx.work.WorkerParameters
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@HiltWorker
|
|
||||||
class TtsDownloadWorker
|
|
||||||
@AssistedInject
|
|
||||||
constructor(
|
|
||||||
@Assisted context: Context,
|
|
||||||
@Assisted params: WorkerParameters,
|
|
||||||
private val remoteTtsRepository: RemoteTtsRepository,
|
|
||||||
) : CoroutineWorker(context, params) {
|
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
|
||||||
val jobId = inputData.getString(KEY_JOB_ID)
|
|
||||||
?: return Result.failure(errorData("Missing job ID"))
|
|
||||||
val bookId = inputData.getString(KEY_BOOK_ID) ?: ""
|
|
||||||
|
|
||||||
val maxAttempts = inputData.getInt(KEY_MAX_ATTEMPTS, DEFAULT_MAX_ATTEMPTS)
|
|
||||||
val pollIntervalMs = inputData.getLong(KEY_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS)
|
|
||||||
|
|
||||||
repeat(maxAttempts) { attempt ->
|
|
||||||
val result = remoteTtsRepository.getJob(jobId)
|
|
||||||
val job = result.getOrElse {
|
|
||||||
Log.e(TAG, "Failed to poll job $jobId (attempt $attempt)", it)
|
|
||||||
return Result.retry()
|
|
||||||
}
|
|
||||||
|
|
||||||
setProgress(
|
|
||||||
progressData(
|
|
||||||
jobId = jobId,
|
|
||||||
status = job.status,
|
|
||||||
progress = job.progress,
|
|
||||||
currentChapter = job.currentChapter,
|
|
||||||
completedChapters = job.completedChapters,
|
|
||||||
totalChapters = job.totalChapters,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
when {
|
|
||||||
job.isCompleted -> {
|
|
||||||
val downloadResult = remoteTtsRepository.downloadAudio(jobId)
|
|
||||||
val audioBytes = downloadResult.getOrElse {
|
|
||||||
Log.e(TAG, "Failed to download audio for job $jobId", it)
|
|
||||||
return Result.failure(errorData(it.message ?: "Download failed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
val outputFile = outputFile(bookId, jobId)
|
|
||||||
outputFile.parentFile?.mkdirs()
|
|
||||||
outputFile.writeBytes(audioBytes)
|
|
||||||
|
|
||||||
Log.i(TAG, "Saved TTS audio for job $jobId to ${outputFile.absolutePath}")
|
|
||||||
return Result.success(
|
|
||||||
successData(
|
|
||||||
jobId = jobId,
|
|
||||||
filePath = outputFile.absolutePath,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
job.isFailed -> {
|
|
||||||
Log.e(TAG, "TTS job $jobId failed: ${job.error}")
|
|
||||||
return Result.failure(errorData(job.error.ifBlank { "TTS job failed" }))
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
delay(pollIntervalMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.failure(errorData("TTS job polling timed out"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun outputFile(bookId: String, jobId: String): File {
|
|
||||||
val safeBookId = bookId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
|
||||||
val safeJobId = jobId.replace(Regex("[^a-zA-Z0-9\\-_]"), "_").take(64)
|
|
||||||
val dir = File(applicationContext.cacheDir, "tts")
|
|
||||||
return File(dir, "${safeBookId}_${safeJobId}.m4b")
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "TtsDownloadWorker"
|
|
||||||
|
|
||||||
const val KEY_JOB_ID = "job_id"
|
|
||||||
const val KEY_BOOK_ID = "book_id"
|
|
||||||
const val KEY_MAX_ATTEMPTS = "max_attempts"
|
|
||||||
const val KEY_POLL_INTERVAL_MS = "poll_interval_ms"
|
|
||||||
|
|
||||||
const val KEY_OUTPUT_FILE_PATH = "output_file_path"
|
|
||||||
const val KEY_OUTPUT_JOB_ID = "output_job_id"
|
|
||||||
const val KEY_ERROR_MESSAGE = "error_message"
|
|
||||||
|
|
||||||
const val PROGRESS_STATUS = "progress_status"
|
|
||||||
const val PROGRESS_PROGRESS = "progress_progress"
|
|
||||||
const val PROGRESS_CURRENT_CHAPTER = "progress_current_chapter"
|
|
||||||
const val PROGRESS_COMPLETED_CHAPTERS = "progress_completed_chapters"
|
|
||||||
const val PROGRESS_TOTAL_CHAPTERS = "progress_total_chapters"
|
|
||||||
|
|
||||||
private const val DEFAULT_MAX_ATTEMPTS = 360
|
|
||||||
private const val DEFAULT_POLL_INTERVAL_MS = 5_000L
|
|
||||||
|
|
||||||
fun createInputData(
|
|
||||||
jobId: String,
|
|
||||||
bookId: String,
|
|
||||||
maxAttempts: Int = DEFAULT_MAX_ATTEMPTS,
|
|
||||||
pollIntervalMs: Long = DEFAULT_POLL_INTERVAL_MS,
|
|
||||||
): Data =
|
|
||||||
Data.Builder()
|
|
||||||
.putString(KEY_JOB_ID, jobId)
|
|
||||||
.putString(KEY_BOOK_ID, bookId)
|
|
||||||
.putInt(KEY_MAX_ATTEMPTS, maxAttempts)
|
|
||||||
.putLong(KEY_POLL_INTERVAL_MS, pollIntervalMs)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun progressData(
|
|
||||||
jobId: String,
|
|
||||||
status: String,
|
|
||||||
progress: Double,
|
|
||||||
currentChapter: String,
|
|
||||||
completedChapters: Int,
|
|
||||||
totalChapters: Int,
|
|
||||||
): Data =
|
|
||||||
Data.Builder()
|
|
||||||
.putString(KEY_JOB_ID, jobId)
|
|
||||||
.putString(PROGRESS_STATUS, status)
|
|
||||||
.putDouble(PROGRESS_PROGRESS, progress)
|
|
||||||
.putString(PROGRESS_CURRENT_CHAPTER, currentChapter)
|
|
||||||
.putInt(PROGRESS_COMPLETED_CHAPTERS, completedChapters)
|
|
||||||
.putInt(PROGRESS_TOTAL_CHAPTERS, totalChapters)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun successData(jobId: String, filePath: String): Data =
|
|
||||||
Data.Builder()
|
|
||||||
.putString(KEY_OUTPUT_JOB_ID, jobId)
|
|
||||||
.putString(KEY_OUTPUT_FILE_PATH, filePath)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun errorData(message: String): Data =
|
|
||||||
Data.Builder()
|
|
||||||
.putString(KEY_ERROR_MESSAGE, message)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -35,12 +35,9 @@ data class Book(
|
||||||
val remoteId: String = "",
|
val remoteId: String = "",
|
||||||
val libraryId: String = "",
|
val libraryId: String = "",
|
||||||
val mediaType: String = "",
|
val mediaType: String = "",
|
||||||
val itemType: String = "",
|
|
||||||
val hasAudio: Boolean = false,
|
val hasAudio: Boolean = false,
|
||||||
val hasEbook: Boolean = false,
|
val hasEbook: Boolean = false,
|
||||||
val audioDuration: Long = 0L,
|
val audioDuration: Long = 0L,
|
||||||
val audioCurrentFile: String = "",
|
|
||||||
val audioCurrentPosition: Long = 0L,
|
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
val lastSyncedAt: Long = 0L,
|
val lastSyncedAt: Long = 0L,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
@ -60,12 +57,9 @@ data class Book(
|
||||||
remoteId = "",
|
remoteId = "",
|
||||||
libraryId = "",
|
libraryId = "",
|
||||||
mediaType = "",
|
mediaType = "",
|
||||||
itemType = "",
|
|
||||||
hasAudio = false,
|
hasAudio = false,
|
||||||
hasEbook = false,
|
hasEbook = false,
|
||||||
audioDuration = 0L,
|
audioDuration = 0L,
|
||||||
audioCurrentFile = "",
|
|
||||||
audioCurrentPosition = 0L,
|
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
lastSyncedAt = 0L,
|
lastSyncedAt = 0L,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.model.player
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for the audio player progress state.
|
|
||||||
*
|
|
||||||
* TODO: When implementing the ExoPlayer/MediaSession audio player,
|
|
||||||
* hook [org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase]
|
|
||||||
* into the playback position update flow, e.g.:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* val book = ...
|
|
||||||
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
|
|
||||||
* val position = player.currentPosition.coerceAtLeast(0L)
|
|
||||||
* val duration = player.duration.coerceAtLeast(book.audioDuration)
|
|
||||||
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
data class AudioPlayerProgress(
|
|
||||||
val bookId: Int,
|
|
||||||
val currentFile: String,
|
|
||||||
val position: Long,
|
|
||||||
val duration: Long,
|
|
||||||
)
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.domain.model.player
|
|
||||||
|
|
||||||
data class AudioTrack(
|
|
||||||
val fileId: String,
|
|
||||||
val title: String,
|
|
||||||
val durationMs: Long,
|
|
||||||
val order: Int,
|
|
||||||
)
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.model.reader
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class ReaderPage(
|
|
||||||
val index: Int,
|
|
||||||
val items: List<ReaderText>,
|
|
||||||
val startTextIndex: Int,
|
|
||||||
val endTextIndex: Int,
|
|
||||||
)
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.model.rsvp
|
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drives RSVP playback for a fixed token stream.
|
|
||||||
*
|
|
||||||
* @param scope scope used for the playback loop (typically `viewModelScope`).
|
|
||||||
*/
|
|
||||||
class RsvpEngine(private val scope: CoroutineScope) {
|
|
||||||
|
|
||||||
data class State(
|
|
||||||
val currentIndex: Int = 0,
|
|
||||||
val isPlaying: Boolean = false,
|
|
||||||
val tokens: List<RsvpToken> = emptyList(),
|
|
||||||
val wpm: Int = 350,
|
|
||||||
val pauseOnParagraphEnd: Boolean = true,
|
|
||||||
val pauseOnChapterEnd: Boolean = true,
|
|
||||||
val pauseOnLongWords: Boolean = true,
|
|
||||||
) {
|
|
||||||
val progress: Float
|
|
||||||
get() = if (tokens.isEmpty()) 0f
|
|
||||||
else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
|
|
||||||
|
|
||||||
val currentToken: RsvpToken?
|
|
||||||
get() = tokens.getOrNull(currentIndex)
|
|
||||||
|
|
||||||
val isAtEnd: Boolean
|
|
||||||
get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(State())
|
|
||||||
val state: StateFlow<State> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var playbackJob: Job? = null
|
|
||||||
|
|
||||||
fun setTokens(tokens: List<RsvpToken>) {
|
|
||||||
stop()
|
|
||||||
_state.value = _state.value.copy(
|
|
||||||
tokens = tokens,
|
|
||||||
currentIndex = 0,
|
|
||||||
isPlaying = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setWpm(wpm: Int) {
|
|
||||||
_state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPauseOnParagraphEnd(value: Boolean) {
|
|
||||||
_state.value = _state.value.copy(pauseOnParagraphEnd = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPauseOnChapterEnd(value: Boolean) {
|
|
||||||
_state.value = _state.value.copy(pauseOnChapterEnd = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPauseOnLongWords(value: Boolean) {
|
|
||||||
_state.value = _state.value.copy(pauseOnLongWords = value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekToIndex(index: Int) {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.tokens.isEmpty()) return
|
|
||||||
val clamped = index.coerceIn(0, s.tokens.lastIndex)
|
|
||||||
_state.value = s.copy(currentIndex = clamped)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekToProgress(progress: Float) {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.tokens.isEmpty()) return
|
|
||||||
val clamped = progress.coerceIn(0f, 1f)
|
|
||||||
val idx = (clamped * s.tokens.lastIndex).toInt()
|
|
||||||
_state.value = s.copy(currentIndex = idx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipForward(count: Int = 10) {
|
|
||||||
seekToIndex(_state.value.currentIndex + count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipBackward(count: Int = 10) {
|
|
||||||
seekToIndex(_state.value.currentIndex - count)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play() {
|
|
||||||
val s = _state.value
|
|
||||||
if (s.tokens.isEmpty()) return
|
|
||||||
if (s.isAtEnd) {
|
|
||||||
seekToIndex(0)
|
|
||||||
}
|
|
||||||
if (_state.value.isPlaying) return
|
|
||||||
_state.value = _state.value.copy(isPlaying = true)
|
|
||||||
startPlaybackLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause() {
|
|
||||||
_state.value = _state.value.copy(isPlaying = false)
|
|
||||||
playbackJob?.cancel()
|
|
||||||
playbackJob = null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggle() {
|
|
||||||
if (_state.value.isPlaying) pause() else play()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPlaybackLoop() {
|
|
||||||
playbackJob?.cancel()
|
|
||||||
playbackJob = scope.launch {
|
|
||||||
while (isActive && _state.value.isPlaying) {
|
|
||||||
val snapshot = _state.value
|
|
||||||
if (snapshot.isAtEnd) {
|
|
||||||
_state.value = snapshot.copy(isPlaying = false)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = snapshot.tokens[snapshot.currentIndex]
|
|
||||||
delay(delayMsFor(token, snapshot))
|
|
||||||
|
|
||||||
val after = _state.value
|
|
||||||
if (!after.isPlaying) return@launch
|
|
||||||
if (after.tokens.isEmpty()) return@launch
|
|
||||||
|
|
||||||
_state.value = after.copy(
|
|
||||||
currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the delay (in ms) before advancing past [token].
|
|
||||||
*
|
|
||||||
* Base = 60_000 / wpm, adjusted by:
|
|
||||||
* - per-word multiplier (long words / digits)
|
|
||||||
* - paragraph-end bonus (configurable, default +200 ms)
|
|
||||||
* - chapter-end bonus (configurable, default +400 ms)
|
|
||||||
*/
|
|
||||||
private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
|
|
||||||
val wpm = snapshot.wpm.coerceAtLeast(50)
|
|
||||||
val baseMs = 60_000.0 / wpm
|
|
||||||
var ms = baseMs * token.multiplier
|
|
||||||
|
|
||||||
if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
|
|
||||||
ms += 200.0
|
|
||||||
}
|
|
||||||
if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
|
|
||||||
ms += 400.0
|
|
||||||
}
|
|
||||||
if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
|
|
||||||
// multiplier already applied above; keep it
|
|
||||||
} else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
|
|
||||||
ms = baseMs
|
|
||||||
}
|
|
||||||
return ms.toLong().coerceAtLeast(20L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.model.rsvp
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One word/segment in the RSVP stream.
|
|
||||||
*
|
|
||||||
* @param word the original word as it appears in the book.
|
|
||||||
* @param pivotIndex index of the optimal recognition point within [word];
|
|
||||||
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
|
|
||||||
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
|
|
||||||
* @param isChapterEnd true if the token follows a chapter heading.
|
|
||||||
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class RsvpToken(
|
|
||||||
val word: String,
|
|
||||||
val pivotIndex: Int,
|
|
||||||
val isParagraphEnd: Boolean,
|
|
||||||
val isChapterEnd: Boolean,
|
|
||||||
val multiplier: Float = 1f,
|
|
||||||
) {
|
|
||||||
val prefix: String
|
|
||||||
get() = word.substring(0, pivotIndex)
|
|
||||||
|
|
||||||
val pivot: String
|
|
||||||
get() = word.substring(pivotIndex, pivotIndex + 1)
|
|
||||||
|
|
||||||
val suffix: String
|
|
||||||
get() = word.substring(pivotIndex + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
|
|
||||||
* Indices are 0-based positions of the focal character.
|
|
||||||
* Values follow the heuristic table from Spritz/speed-reading literature:
|
|
||||||
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
|
||||||
*/
|
|
||||||
object OrpTable {
|
|
||||||
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
|
|
||||||
|
|
||||||
fun pivotFor(word: String): Int {
|
|
||||||
val len = word.length
|
|
||||||
if (len == 0) return 0
|
|
||||||
if (len <= TABLE.size) return TABLE[len - 1]
|
|
||||||
return (len * 0.3f).toInt().coerceIn(1, len - 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.model.rsvp
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
|
|
||||||
* into a flat list of [RsvpToken]s suitable for RSVP playback.
|
|
||||||
*
|
|
||||||
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
|
|
||||||
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
|
|
||||||
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
|
|
||||||
* - Strips punctuation-only tokens and empty whitespace tokens.
|
|
||||||
*/
|
|
||||||
object RsvpTokenizer {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
|
|
||||||
*/
|
|
||||||
fun tokenizeReaderText(blocks: List<ReaderText>): List<RsvpToken> {
|
|
||||||
val tokens = ArrayList<RsvpToken>(blocks.size * 8)
|
|
||||||
var lastWasParagraphEnd = false
|
|
||||||
var pendingChapterEnd = false
|
|
||||||
|
|
||||||
for (block in blocks) {
|
|
||||||
when (block) {
|
|
||||||
is ReaderText.Chapter -> {
|
|
||||||
pendingChapterEnd = true
|
|
||||||
lastWasParagraphEnd = false
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Text -> {
|
|
||||||
val raw = block.line.text
|
|
||||||
if (raw.isBlank()) {
|
|
||||||
lastWasParagraphEnd = true
|
|
||||||
} else {
|
|
||||||
tokenizeLine(
|
|
||||||
line = raw,
|
|
||||||
isParagraphEnd = lastWasParagraphEnd,
|
|
||||||
isChapterEnd = pendingChapterEnd,
|
|
||||||
out = tokens,
|
|
||||||
)
|
|
||||||
lastWasParagraphEnd = false
|
|
||||||
pendingChapterEnd = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Separator -> lastWasParagraphEnd = true
|
|
||||||
is ReaderText.Image -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tokenizeLine(
|
|
||||||
line: String,
|
|
||||||
isParagraphEnd: Boolean,
|
|
||||||
isChapterEnd: Boolean,
|
|
||||||
out: MutableList<RsvpToken>,
|
|
||||||
) {
|
|
||||||
val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
|
|
||||||
if (words.isEmpty()) return
|
|
||||||
|
|
||||||
val lastIndex = words.lastIndex
|
|
||||||
for ((idx, word) in words.withIndex()) {
|
|
||||||
val cleaned = cleanWord(word)
|
|
||||||
if (cleaned.isEmpty()) continue
|
|
||||||
val pivot = OrpTable.pivotFor(cleaned)
|
|
||||||
val paragraphEnd = isParagraphEnd && idx == lastIndex
|
|
||||||
val multiplier = computeMultiplier(cleaned)
|
|
||||||
out.add(
|
|
||||||
RsvpToken(
|
|
||||||
word = cleaned,
|
|
||||||
pivotIndex = pivot,
|
|
||||||
isParagraphEnd = paragraphEnd,
|
|
||||||
isChapterEnd = isChapterEnd && idx == 0,
|
|
||||||
multiplier = multiplier,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val WORD_SPLIT_REGEX = Regex("\\s+")
|
|
||||||
|
|
||||||
private fun cleanWord(raw: String): String {
|
|
||||||
val sb = StringBuilder(raw.length)
|
|
||||||
var i = 0
|
|
||||||
val len = raw.length
|
|
||||||
while (i < len) {
|
|
||||||
val c = raw[i]
|
|
||||||
if (c.isLetterOrDigit() || c == '-' || c == '\'') {
|
|
||||||
sb.append(c)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-word pacing multiplier.
|
|
||||||
*
|
|
||||||
* - Long words (>9 chars) get +30% time for recognition.
|
|
||||||
* - Numbers/digits get +20% time.
|
|
||||||
* - Baseline 1.0 for normal words.
|
|
||||||
*/
|
|
||||||
private fun computeMultiplier(word: String): Float {
|
|
||||||
val len = word.length
|
|
||||||
val base = when {
|
|
||||||
len > 9 -> 1.3f
|
|
||||||
len > 6 -> 1.1f
|
|
||||||
else -> 1.0f
|
|
||||||
}
|
|
||||||
val hasDigit = word.any { it.isDigit() }
|
|
||||||
return if (hasDigit) base + 0.2f else base
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.model.tts
|
|
||||||
|
|
||||||
data class TtsEngine(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val supportsStreaming: Boolean,
|
|
||||||
val supportsCloning: Boolean,
|
|
||||||
val maxTextLength: Int,
|
|
||||||
val needsNetwork: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TtsVoice(
|
|
||||||
val id: String,
|
|
||||||
val name: String,
|
|
||||||
val language: String,
|
|
||||||
val engine: String,
|
|
||||||
val gender: String,
|
|
||||||
val quality: String,
|
|
||||||
val requiresReference: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TtsJob(
|
|
||||||
val jobId: String,
|
|
||||||
val bookId: String,
|
|
||||||
val title: String,
|
|
||||||
val author: String,
|
|
||||||
val engine: String,
|
|
||||||
val voiceId: String,
|
|
||||||
val speed: Double,
|
|
||||||
val status: String,
|
|
||||||
val progress: Double,
|
|
||||||
val currentChapter: String,
|
|
||||||
val totalChapters: Int,
|
|
||||||
val completedChapters: Int,
|
|
||||||
val outputPath: String,
|
|
||||||
val error: String,
|
|
||||||
val createdAt: Double,
|
|
||||||
val startedAt: Double,
|
|
||||||
val completedAt: Double,
|
|
||||||
) {
|
|
||||||
val isCompleted: Boolean get() = status == "completed"
|
|
||||||
val isFailed: Boolean get() = status == "failed" || status == "error"
|
|
||||||
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.domain.repository
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
|
||||||
|
|
||||||
interface AudiobookshelfRepository {
|
|
||||||
suspend fun getAudioTracks(itemId: String): Result<List<AudioTrack>>
|
|
||||||
}
|
|
||||||
|
|
@ -43,6 +43,4 @@ interface BookRepository {
|
||||||
suspend fun getDefaultCover(
|
suspend fun getDefaultCover(
|
||||||
book: Book
|
book: Book
|
||||||
): Result<CoverImage?>
|
): Result<CoverImage?>
|
||||||
|
|
||||||
suspend fun getBooksWithRemoteId(): Result<List<Book>>
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.repository
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
|
|
||||||
interface ProgressRepository {
|
|
||||||
suspend fun syncReadingProgress(
|
|
||||||
book: Book,
|
|
||||||
lastChapter: String? = null,
|
|
||||||
): Result<Unit>
|
|
||||||
|
|
||||||
suspend fun syncPlaybackProgress(
|
|
||||||
book: Book,
|
|
||||||
currentFile: String,
|
|
||||||
position: Long,
|
|
||||||
duration: Long,
|
|
||||||
): Result<Unit>
|
|
||||||
|
|
||||||
suspend fun getBooksWithRemoteId(): Result<List<Book>>
|
|
||||||
}
|
|
||||||
|
|
@ -6,11 +6,6 @@
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.repository
|
package org.dueattendant149.bookshelf.domain.repository
|
||||||
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||||
|
|
||||||
|
|
@ -18,8 +13,4 @@ interface RemoteLibraryRepository {
|
||||||
suspend fun getLibraries(): Result<List<RemoteLibrary>>
|
suspend fun getLibraries(): Result<List<RemoteLibrary>>
|
||||||
suspend fun getBooks(libraryId: String): Result<List<Book>>
|
suspend fun getBooks(libraryId: String): Result<List<Book>>
|
||||||
suspend fun searchLibrary(libraryId: String, query: String): Result<List<Book>>
|
suspend fun searchLibrary(libraryId: String, query: String): Result<List<Book>>
|
||||||
suspend fun search(request: SearchRequest): Result<SearchResponse>
|
|
||||||
suspend fun startDownload(request: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest): Result<org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse>
|
|
||||||
suspend fun listDownloads(): Result<org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse>
|
|
||||||
suspend fun uploadBook(file: MultipartBody.Part, bookType: RequestBody): Result<UploadBookResponse>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.repository
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
|
||||||
|
|
||||||
interface RemoteTtsRepository {
|
|
||||||
suspend fun fetchEngines(): Result<List<TtsEngine>>
|
|
||||||
suspend fun fetchVoices(engine: String? = null): Result<List<TtsVoice>>
|
|
||||||
suspend fun createJob(
|
|
||||||
bookId: String,
|
|
||||||
engine: String,
|
|
||||||
voiceId: String,
|
|
||||||
speed: Double,
|
|
||||||
): Result<TtsJob>
|
|
||||||
|
|
||||||
suspend fun getJob(jobId: String): Result<TtsJob>
|
|
||||||
suspend fun downloadAudio(jobId: String): Result<ByteArray>
|
|
||||||
}
|
|
||||||
|
|
@ -1,380 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.reader
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.TextLayoutResult
|
|
||||||
import androidx.compose.ui.text.TextMeasurer
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.Constraints
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
|
||||||
import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
|
||||||
class PaginateReaderTextUseCase @Inject constructor() {
|
|
||||||
|
|
||||||
operator fun invoke(
|
|
||||||
text: List<ReaderText>,
|
|
||||||
contentWidthPx: Int,
|
|
||||||
contentHeightPx: Int,
|
|
||||||
paragraphStyle: TextStyle,
|
|
||||||
chapterTitleAlignment: ReaderTextAlignment,
|
|
||||||
paragraphSpacingPx: Int,
|
|
||||||
textMeasurer: TextMeasurer,
|
|
||||||
imageMaxWidthPx: Int,
|
|
||||||
chapterKeepLines: Int = 2,
|
|
||||||
minWidowLines: Int = 2,
|
|
||||||
minOrphanLines: Int = 2,
|
|
||||||
safetyHeightPx: Int = 4,
|
|
||||||
chapterExtraHeightPx: Int = 0,
|
|
||||||
separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX,
|
|
||||||
defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX,
|
|
||||||
): List<ReaderPage> {
|
|
||||||
if (text.isEmpty() || contentWidthPx <= 0 || contentHeightPx <= 0) return emptyList()
|
|
||||||
|
|
||||||
val availableHeight = (contentHeightPx - safetyHeightPx).coerceAtLeast(1)
|
|
||||||
val workingBlocks = text.toMutableList()
|
|
||||||
val metrics = workingBlocks.map { block ->
|
|
||||||
measureBlock(
|
|
||||||
block = block,
|
|
||||||
contentWidthPx = contentWidthPx,
|
|
||||||
imageMaxWidthPx = imageMaxWidthPx,
|
|
||||||
paragraphStyle = paragraphStyle,
|
|
||||||
chapterTitleAlignment = chapterTitleAlignment,
|
|
||||||
textMeasurer = textMeasurer,
|
|
||||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
|
||||||
separatorHeightPx = separatorHeightPx,
|
|
||||||
defaultImageHeightPx = defaultImageHeightPx,
|
|
||||||
)
|
|
||||||
}.toMutableList()
|
|
||||||
|
|
||||||
val pages = mutableListOf<ReaderPage>()
|
|
||||||
val pageItems = mutableListOf<ReaderText>()
|
|
||||||
var startTextIndex = 0
|
|
||||||
var remainingHeight = availableHeight
|
|
||||||
var currentPageIndex = 0
|
|
||||||
var index = 0
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the height of [metric] plus the inter-item spacing if this
|
|
||||||
* block is not the first one on the current page. This matches the
|
|
||||||
* [Arrangement.spacedBy] used in the paginated reader UI.
|
|
||||||
*/
|
|
||||||
fun heightWithSpacing(metric: BlockMetrics): Int {
|
|
||||||
return if (pageItems.isEmpty()) metric.height else metric.height + paragraphSpacingPx
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emitPage(endTextIndex: Int) {
|
|
||||||
if (pageItems.isEmpty()) return
|
|
||||||
pages.add(
|
|
||||||
ReaderPage(
|
|
||||||
index = currentPageIndex,
|
|
||||||
items = pageItems.toList(),
|
|
||||||
startTextIndex = startTextIndex,
|
|
||||||
endTextIndex = endTextIndex,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
currentPageIndex++
|
|
||||||
pageItems.clear()
|
|
||||||
remainingHeight = availableHeight
|
|
||||||
startTextIndex = endTextIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
while (index < workingBlocks.size) {
|
|
||||||
val block = workingBlocks[index]
|
|
||||||
val metric = metrics[index]
|
|
||||||
val blockHeight = heightWithSpacing(metric)
|
|
||||||
|
|
||||||
when (block) {
|
|
||||||
is ReaderText.Chapter -> {
|
|
||||||
val keep = followingTextKeepHeight(
|
|
||||||
blocks = workingBlocks,
|
|
||||||
metrics = metrics,
|
|
||||||
startIndex = index + 1,
|
|
||||||
keepLines = chapterKeepLines,
|
|
||||||
)
|
|
||||||
// Heading itself + spacing after it (if anything follows) + kept text.
|
|
||||||
val requiredWithKeep = blockHeight +
|
|
||||||
(if (index < workingBlocks.lastIndex) paragraphSpacingPx else 0) +
|
|
||||||
keep
|
|
||||||
|
|
||||||
when {
|
|
||||||
requiredWithKeep > availableHeight -> {
|
|
||||||
// Heading + kept lines don't fit on a fresh page.
|
|
||||||
// Place heading alone.
|
|
||||||
if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
|
|
||||||
emitPage(index)
|
|
||||||
}
|
|
||||||
pageItems.add(block)
|
|
||||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
|
|
||||||
requiredWithKeep > remainingHeight && pageItems.isNotEmpty() -> {
|
|
||||||
// Move heading to the next page so it stays with following text.
|
|
||||||
emitPage(index)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
pageItems.add(block)
|
|
||||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Separator,
|
|
||||||
is ReaderText.Image -> {
|
|
||||||
if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
|
|
||||||
emitPage(index)
|
|
||||||
}
|
|
||||||
pageItems.add(block)
|
|
||||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Text -> {
|
|
||||||
if (blockHeight <= remainingHeight) {
|
|
||||||
pageItems.add(block)
|
|
||||||
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
|
||||||
index++
|
|
||||||
} else if (pageItems.isNotEmpty()) {
|
|
||||||
emitPage(index)
|
|
||||||
// Retry the same block on a fresh page.
|
|
||||||
} else {
|
|
||||||
// Fresh page and the paragraph still doesn't fit whole.
|
|
||||||
val split = splitParagraph(
|
|
||||||
block = block,
|
|
||||||
metric = metric,
|
|
||||||
availableHeight = remainingHeight,
|
|
||||||
minWidowLines = minWidowLines,
|
|
||||||
minOrphanLines = minOrphanLines,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (split == null) {
|
|
||||||
// Whole paragraph fits on a fresh page but we shouldn't be here.
|
|
||||||
// Safety fallback: place it anyway.
|
|
||||||
pageItems.add(block)
|
|
||||||
remainingHeight = 0
|
|
||||||
index++
|
|
||||||
} else {
|
|
||||||
val (head, tail) = split
|
|
||||||
pageItems.add(ReaderText.Text(head))
|
|
||||||
emitPage(index + 1)
|
|
||||||
// The remainder is still the same logical block, so the next page
|
|
||||||
// should start at this index rather than index + 1.
|
|
||||||
startTextIndex = index
|
|
||||||
|
|
||||||
if (tail.isBlank()) {
|
|
||||||
index++
|
|
||||||
} else {
|
|
||||||
// Replace current block with remainder and retry on fresh page.
|
|
||||||
val remainderBlock = ReaderText.Text(tail)
|
|
||||||
workingBlocks[index] = remainderBlock
|
|
||||||
metrics[index] = measureBlock(
|
|
||||||
block = remainderBlock,
|
|
||||||
contentWidthPx = contentWidthPx,
|
|
||||||
imageMaxWidthPx = imageMaxWidthPx,
|
|
||||||
paragraphStyle = paragraphStyle,
|
|
||||||
chapterTitleAlignment = chapterTitleAlignment,
|
|
||||||
textMeasurer = textMeasurer,
|
|
||||||
chapterExtraHeightPx = chapterExtraHeightPx,
|
|
||||||
separatorHeightPx = separatorHeightPx,
|
|
||||||
defaultImageHeightPx = defaultImageHeightPx,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageItems.isNotEmpty()) {
|
|
||||||
emitPage(workingBlocks.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d("Pagination", "Split ${text.size} blocks into ${pages.size} pages")
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun measureBlock(
|
|
||||||
block: ReaderText,
|
|
||||||
contentWidthPx: Int,
|
|
||||||
imageMaxWidthPx: Int,
|
|
||||||
paragraphStyle: TextStyle,
|
|
||||||
chapterTitleAlignment: ReaderTextAlignment,
|
|
||||||
textMeasurer: TextMeasurer,
|
|
||||||
chapterExtraHeightPx: Int,
|
|
||||||
separatorHeightPx: Int,
|
|
||||||
defaultImageHeightPx: Int,
|
|
||||||
): BlockMetrics {
|
|
||||||
return when (block) {
|
|
||||||
is ReaderText.Text -> {
|
|
||||||
val layout = measureText(block.line, paragraphStyle, contentWidthPx, textMeasurer)
|
|
||||||
val textHeight = layout?.size?.height ?: 0
|
|
||||||
val lineCount = layout?.lineCount ?: 0
|
|
||||||
val lineHeights = (0 until lineCount).map { lineIndex ->
|
|
||||||
layout!!.getLineBottom(lineIndex)
|
|
||||||
}
|
|
||||||
BlockMetrics(
|
|
||||||
height = textHeight,
|
|
||||||
lineHeights = lineHeights,
|
|
||||||
isSplittable = true,
|
|
||||||
layout = layout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Chapter -> {
|
|
||||||
val chapterStyle = readerChapterTextStyle(
|
|
||||||
nested = block.nested,
|
|
||||||
textAlignment = chapterTitleAlignment,
|
|
||||||
)
|
|
||||||
val layout = measureText(
|
|
||||||
AnnotatedString(block.title),
|
|
||||||
chapterStyle,
|
|
||||||
contentWidthPx,
|
|
||||||
textMeasurer,
|
|
||||||
)
|
|
||||||
val titleHeight = layout?.size?.height ?: 0
|
|
||||||
BlockMetrics(
|
|
||||||
height = titleHeight + chapterExtraHeightPx,
|
|
||||||
isSplittable = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderText.Separator -> BlockMetrics(
|
|
||||||
height = separatorHeightPx,
|
|
||||||
isSplittable = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
is ReaderText.Image -> BlockMetrics(
|
|
||||||
height = imageHeight(block, imageMaxWidthPx, defaultImageHeightPx),
|
|
||||||
isSplittable = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the height of the first [keepLines] lines of the text block at
|
|
||||||
* [startIndex], or the full height of the block if it has fewer lines. If
|
|
||||||
* the next block is not splittable (e.g. an image), its full height is
|
|
||||||
* returned so the heading can be moved to the next page when necessary.
|
|
||||||
*/
|
|
||||||
private fun followingTextKeepHeight(
|
|
||||||
blocks: List<ReaderText>,
|
|
||||||
metrics: List<BlockMetrics>,
|
|
||||||
startIndex: Int,
|
|
||||||
keepLines: Int,
|
|
||||||
): Int {
|
|
||||||
if (startIndex >= blocks.size) return 0
|
|
||||||
val metric = metrics[startIndex]
|
|
||||||
val block = blocks[startIndex]
|
|
||||||
|
|
||||||
if (metric.lineHeights.isEmpty()) return metric.height
|
|
||||||
|
|
||||||
if (block !is ReaderText.Text) {
|
|
||||||
// Keep the whole non-text block with the heading.
|
|
||||||
return metric.height
|
|
||||||
}
|
|
||||||
|
|
||||||
val linesToKeep = minOf(keepLines, metric.lineHeights.size)
|
|
||||||
return metric.lineHeights[linesToKeep - 1].toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun splitParagraph(
|
|
||||||
block: ReaderText.Text,
|
|
||||||
metric: BlockMetrics,
|
|
||||||
availableHeight: Int,
|
|
||||||
minWidowLines: Int,
|
|
||||||
minOrphanLines: Int,
|
|
||||||
): Pair<AnnotatedString, AnnotatedString>? {
|
|
||||||
val layout = metric.layout ?: return null
|
|
||||||
val lineCount = layout.lineCount
|
|
||||||
if (lineCount == 0) return null
|
|
||||||
|
|
||||||
// Find the last line whose bottom fits inside the available height.
|
|
||||||
val lastFittingLine = layout.getLineForVerticalPosition(availableHeight.toFloat())
|
|
||||||
.coerceIn(0, lineCount - 1)
|
|
||||||
|
|
||||||
val maxLinesCurrent = lastFittingLine + 1
|
|
||||||
val minLinesCurrent = minWidowLines.coerceAtLeast(1)
|
|
||||||
val minLinesRemainder = minOrphanLines.coerceAtLeast(1)
|
|
||||||
|
|
||||||
// Ideal break: current page has at least minWidowLines,
|
|
||||||
// remainder has at least minOrphanLines.
|
|
||||||
val idealMax = (lineCount - minLinesRemainder).coerceAtLeast(minLinesCurrent)
|
|
||||||
val candidateLines = maxLinesCurrent.coerceIn(minLinesCurrent, idealMax)
|
|
||||||
|
|
||||||
if (candidateLines in minLinesCurrent..idealMax) {
|
|
||||||
val breakChar = layout.getLineEnd(candidateLines - 1, visibleEnd = true)
|
|
||||||
if (breakChar in 1 until block.line.length) {
|
|
||||||
return block.line.subSequence(0, breakChar) to
|
|
||||||
block.line.subSequence(breakChar, block.line.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph is longer than a full page or constraints cannot be satisfied.
|
|
||||||
// Split at the last line that actually fits.
|
|
||||||
val forcedLines = maxLinesCurrent.coerceIn(1, lineCount - 1)
|
|
||||||
val breakChar = layout.getLineEnd(forcedLines - 1, visibleEnd = true)
|
|
||||||
if (breakChar in 1 until block.line.length) {
|
|
||||||
return block.line.subSequence(0, breakChar) to
|
|
||||||
block.line.subSequence(breakChar, block.line.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun measureText(
|
|
||||||
text: AnnotatedString,
|
|
||||||
style: TextStyle,
|
|
||||||
maxWidthPx: Int,
|
|
||||||
textMeasurer: TextMeasurer,
|
|
||||||
): TextLayoutResult? {
|
|
||||||
if (text.isBlank()) return null
|
|
||||||
return try {
|
|
||||||
textMeasurer.measure(
|
|
||||||
text = text,
|
|
||||||
style = style,
|
|
||||||
constraints = Constraints(maxWidth = maxWidthPx),
|
|
||||||
softWrap = true,
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("Pagination", "measure failed", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun imageHeight(
|
|
||||||
block: ReaderText.Image,
|
|
||||||
imageMaxWidthPx: Int,
|
|
||||||
defaultImageHeightPx: Int,
|
|
||||||
): Int {
|
|
||||||
val bitmap = block.imageBitmap
|
|
||||||
val width = bitmap.width
|
|
||||||
val height = bitmap.height
|
|
||||||
if (width <= 0 || height <= 0) return defaultImageHeightPx
|
|
||||||
return (imageMaxWidthPx * height / width).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class BlockMetrics(
|
|
||||||
val height: Int,
|
|
||||||
val lineHeights: List<Float> = emptyList(),
|
|
||||||
val isSplittable: Boolean = false,
|
|
||||||
val layout: TextLayoutResult? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12
|
|
||||||
private const val DEFAULT_IMAGE_HEIGHT_PX = 720
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class GlobalSearchUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteLibraryRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(request: SearchRequest): Result<SearchResponse> =
|
|
||||||
repository.search(request)
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class ListDownloadsUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteLibraryRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(): Result<DownloadsListResponse> =
|
|
||||||
repository.listDownloads()
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class StartDownloadUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteLibraryRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(request: DownloadRequest): Result<DownloadResponse> =
|
|
||||||
repository.startDownload(request)
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class SyncPlaybackProgressUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val progressRepository: ProgressRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
book: Book,
|
|
||||||
currentFile: String,
|
|
||||||
position: Long,
|
|
||||||
duration: Long,
|
|
||||||
): Result<Unit> {
|
|
||||||
if (book.remoteId.isBlank()) {
|
|
||||||
return Result.success(Unit)
|
|
||||||
}
|
|
||||||
return progressRepository.syncPlaybackProgress(
|
|
||||||
book = book,
|
|
||||||
currentFile = currentFile,
|
|
||||||
position = position,
|
|
||||||
duration = duration,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class SyncReadingProgressUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val progressRepository: ProgressRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
book: Book,
|
|
||||||
lastChapter: String? = null,
|
|
||||||
): Result<Unit> {
|
|
||||||
if (book.remoteId.isBlank()) {
|
|
||||||
return Result.success(Unit)
|
|
||||||
}
|
|
||||||
return progressRepository.syncReadingProgress(book, lastChapter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.remote
|
|
||||||
|
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
||||||
import okhttp3.MultipartBody
|
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class UploadBookUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteLibraryRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(file: File, bookType: String): Result<String> {
|
|
||||||
val mediaType = file.name.let { name ->
|
|
||||||
when {
|
|
||||||
name.endsWith(".epub", ignoreCase = true) -> "application/epub+zip"
|
|
||||||
name.endsWith(".fb2", ignoreCase = true) -> "application/fb2+xml"
|
|
||||||
name.endsWith(".pdf", ignoreCase = true) -> "application/pdf"
|
|
||||||
name.endsWith(".mobi", ignoreCase = true) -> "application/x-mobipocket-ebook"
|
|
||||||
name.endsWith(".mp3", ignoreCase = true) -> "audio/mpeg"
|
|
||||||
name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> "audio/mp4"
|
|
||||||
name.endsWith(".zip", ignoreCase = true) -> "application/zip"
|
|
||||||
else -> "application/octet-stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val filePart = MultipartBody.Part.createFormData(
|
|
||||||
"file",
|
|
||||||
file.name,
|
|
||||||
file.asRequestBody(mediaType.toMediaTypeOrNull()),
|
|
||||||
)
|
|
||||||
val typePart = bookType.toRequestBody("text/plain".toMediaTypeOrNull())
|
|
||||||
return repository.uploadBook(filePart, typePart).map { response ->
|
|
||||||
if (response.success) "Uploaded: ${response.filename}"
|
|
||||||
else response.errorMessage ?: "Upload failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class CreateTtsJobUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteTtsRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
bookId: String,
|
|
||||||
engine: String,
|
|
||||||
voiceId: String,
|
|
||||||
speed: Double,
|
|
||||||
): Result<TtsJob> = repository.createJob(bookId, engine, voiceId, speed)
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class FetchTtsEnginesUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteTtsRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(): Result<List<TtsEngine>> = repository.fetchEngines()
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class FetchTtsVoicesUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteTtsRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(engine: String? = null): Result<List<TtsVoice>> =
|
|
||||||
repository.fetchVoices(engine)
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.use_case.tts
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.RemoteTtsRepository
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class GetTtsJobUseCase
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val repository: RemoteTtsRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(jobId: String): Result<TtsJob> = repository.getJob(jobId)
|
|
||||||
}
|
|
||||||
|
|
@ -20,19 +20,14 @@ fun String.isValidUri(): Boolean {
|
||||||
/**
|
/**
|
||||||
* Returns true if the string starts with http:// or https:// (case-insensitive).
|
* Returns true if the string starts with http:// or https:// (case-insensitive).
|
||||||
*/
|
*/
|
||||||
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
|
fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX)
|
||||||
|
|
||||||
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
|
* Adds https:// if the string has no http/https scheme.
|
||||||
*/
|
*/
|
||||||
fun String.ensureUriScheme(): String {
|
fun String.ensureUriScheme(): String {
|
||||||
val trimmed = trim()
|
val trimmed = trim()
|
||||||
if (trimmed.hasUriScheme()) return trimmed
|
return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
|
||||||
val hostPart = trimmed.substringBefore("/")
|
|
||||||
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
|
|
||||||
return "$scheme$trimmed"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
|
||||||
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class AudiobooksModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
|
||||||
fetchBooksUseCase: FetchBooksUseCase,
|
|
||||||
cacheBookUseCase: CacheBookUseCase,
|
|
||||||
deleteCacheUseCase: DeleteCacheUseCase,
|
|
||||||
getCacheStatusUseCase: GetCacheStatusUseCase,
|
|
||||||
serverSettings: ServerSettings,
|
|
||||||
serverStatusMonitor: ServerStatusMonitor,
|
|
||||||
) : RemoteLibraryViewModel(
|
|
||||||
fetchLibrariesUseCase,
|
|
||||||
fetchBooksUseCase,
|
|
||||||
cacheBookUseCase,
|
|
||||||
deleteCacheUseCase,
|
|
||||||
getCacheStatusUseCase,
|
|
||||||
serverSettings,
|
|
||||||
serverStatusMonitor,
|
|
||||||
) {
|
|
||||||
override val libraryNameFilter: String? = "audiobook"
|
|
||||||
override val itemTypeFilter: String? = "audiobook"
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
|
||||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
object AudiobooksScreen : Screen, Parcelable {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<AudiobooksModel>()
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
|
|
||||||
LaunchedEffect(model.effects, navigator) {
|
|
||||||
model.effects.collectLatest { effect ->
|
|
||||||
when (effect) {
|
|
||||||
is RemoteLibraryEffect.OnNavigateToReader -> {
|
|
||||||
HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
|
|
||||||
navigator.push(ReaderScreen(effect.bookId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoteLibraryContent(
|
|
||||||
model = model,
|
|
||||||
titleRes = R.string.audiobooks_screen,
|
|
||||||
onPlayAudio = { book ->
|
|
||||||
navigator.push(PlayerScreen(book))
|
|
||||||
},
|
|
||||||
navigateToTts = { bookRemoteId ->
|
|
||||||
// Not used for audiobooks; secondary button opens chapters.
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
|
||||||
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BooksModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
|
||||||
fetchBooksUseCase: FetchBooksUseCase,
|
|
||||||
cacheBookUseCase: CacheBookUseCase,
|
|
||||||
deleteCacheUseCase: DeleteCacheUseCase,
|
|
||||||
getCacheStatusUseCase: GetCacheStatusUseCase,
|
|
||||||
serverSettings: ServerSettings,
|
|
||||||
serverStatusMonitor: ServerStatusMonitor,
|
|
||||||
) : RemoteLibraryViewModel(
|
|
||||||
fetchLibrariesUseCase,
|
|
||||||
fetchBooksUseCase,
|
|
||||||
cacheBookUseCase,
|
|
||||||
deleteCacheUseCase,
|
|
||||||
getCacheStatusUseCase,
|
|
||||||
serverSettings,
|
|
||||||
serverStatusMonitor,
|
|
||||||
) {
|
|
||||||
override val libraryNameFilter: String? = "book"
|
|
||||||
override val itemTypeFilter: String? = "book"
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
|
||||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
object BooksScreen : Screen, Parcelable {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<BooksModel>()
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
|
|
||||||
LaunchedEffect(model.effects, navigator) {
|
|
||||||
model.effects.collectLatest { effect ->
|
|
||||||
when (effect) {
|
|
||||||
is RemoteLibraryEffect.OnNavigateToReader -> {
|
|
||||||
HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
|
|
||||||
navigator.push(ReaderScreen(effect.bookId))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RemoteLibraryContent(
|
|
||||||
model = model,
|
|
||||||
titleRes = R.string.books_screen,
|
|
||||||
onPlayAudio = { book ->
|
|
||||||
navigator.push(PlayerScreen(book))
|
|
||||||
},
|
|
||||||
navigateToTts = { bookRemoteId ->
|
|
||||||
navigator.push(TtsScreen(bookRemoteId))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -6,9 +6,23 @@
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||||
|
|
@ -16,26 +30,145 @@ import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class RemoteLibraryState(
|
||||||
|
val libraries: List<RemoteLibrary> = emptyList(),
|
||||||
|
val books: List<Book> = emptyList(),
|
||||||
|
val selectedLibrary: RemoteLibrary? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
||||||
|
val offlineOnly: Boolean = false,
|
||||||
|
val cachingBookId: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class RemoteLibraryModel
|
class RemoteLibraryModel
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||||
fetchBooksUseCase: FetchBooksUseCase,
|
private val fetchBooksUseCase: FetchBooksUseCase,
|
||||||
cacheBookUseCase: CacheBookUseCase,
|
private val cacheBookUseCase: CacheBookUseCase,
|
||||||
deleteCacheUseCase: DeleteCacheUseCase,
|
private val deleteCacheUseCase: DeleteCacheUseCase,
|
||||||
getCacheStatusUseCase: GetCacheStatusUseCase,
|
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||||
serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
serverStatusMonitor: ServerStatusMonitor,
|
) : ViewModel() {
|
||||||
) : RemoteLibraryViewModel(
|
private val _state = MutableStateFlow(RemoteLibraryState())
|
||||||
fetchLibrariesUseCase,
|
val state = _state.asStateFlow()
|
||||||
fetchBooksUseCase,
|
private var cacheStatusJob: Job? = null
|
||||||
cacheBookUseCase,
|
|
||||||
deleteCacheUseCase,
|
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||||
getCacheStatusUseCase,
|
val effects = _effects.asSharedFlow()
|
||||||
serverSettings,
|
|
||||||
serverStatusMonitor,
|
init {
|
||||||
) {
|
loadLibraries()
|
||||||
override val libraryNameFilter: String? = null
|
}
|
||||||
override val itemTypeFilter: String? = null
|
|
||||||
|
fun loadLibraries() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
if (!serverSettings.hasCredentials()) {
|
||||||
|
_state.update { it.copy(isLoading = false, error = "Server not configured") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val result = fetchLibrariesUseCase()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
libraries = result.getOrDefault(emptyList()),
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectLibrary(library: RemoteLibrary) {
|
||||||
|
_state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
|
||||||
|
loadBooks(library.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBooks(libraryId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
val result = fetchBooksUseCase(libraryId)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
books = result.getOrDefault(emptyList()),
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
observeCacheStatuses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheBook(book: Book) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
|
val result = cacheBookUseCase(book)
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
|
if (result.isFailure) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeCacheStatuses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCache(book: Book) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = deleteCacheUseCase(book.remoteId)
|
||||||
|
if (result.isFailure) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleOfflineOnly() {
|
||||||
|
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openBook(book: Book) {
|
||||||
|
if (!book.hasEbook || book.remoteId.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
cacheBookUseCase(book)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
|
|
||||||
|
result
|
||||||
|
.onSuccess { bookId ->
|
||||||
|
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
_state.update { it.copy(error = error.message ?: "Could not open book") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeCacheStatuses() {
|
||||||
|
cacheStatusJob?.cancel()
|
||||||
|
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
||||||
|
if (remoteIds.isEmpty()) {
|
||||||
|
_state.update { it.copy(cacheStatuses = emptyMap()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheStatusJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
remoteIds.map { remoteId ->
|
||||||
|
getCacheStatusUseCase(remoteId)
|
||||||
|
},
|
||||||
|
) { statuses ->
|
||||||
|
statuses.toList().associateBy { it.remoteId }
|
||||||
|
}.collect { statuses ->
|
||||||
|
_state.update { it.copy(cacheStatuses = statuses) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,9 @@ import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
|
||||||
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
|
||||||
|
|
@ -40,15 +37,6 @@ object RemoteLibraryScreen : Screen, Parcelable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RemoteLibraryContent(
|
RemoteLibraryContent(model = model)
|
||||||
model = model,
|
|
||||||
titleRes = R.string.bookshelf_screen,
|
|
||||||
onPlayAudio = { book ->
|
|
||||||
navigator.push(PlayerScreen(book))
|
|
||||||
},
|
|
||||||
navigateToTts = { bookRemoteId ->
|
|
||||||
navigator.push(TtsScreen(bookRemoteId))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared UI state for remote-library tabs.
|
|
||||||
*/
|
|
||||||
data class RemoteLibraryState(
|
|
||||||
val libraries: List<RemoteLibrary> = emptyList(),
|
|
||||||
val books: List<Book> = emptyList(),
|
|
||||||
val selectedLibrary: RemoteLibrary? = null,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
|
||||||
val offlineOnly: Boolean = false,
|
|
||||||
val cachingBookId: String? = null,
|
|
||||||
val serverOnline: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base ViewModel for tabs that display remote libraries filtered by media type.
|
|
||||||
*
|
|
||||||
* @param mediaTypeFilter null means no filtering (legacy "bookshelf" view).
|
|
||||||
*/
|
|
||||||
abstract class RemoteLibraryViewModel(
|
|
||||||
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
|
||||||
private val fetchBooksUseCase: FetchBooksUseCase,
|
|
||||||
private val cacheBookUseCase: CacheBookUseCase,
|
|
||||||
private val deleteCacheUseCase: DeleteCacheUseCase,
|
|
||||||
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
private val serverStatusMonitor: ServerStatusMonitor,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to pick the default library by name when the backend has multiple libraries.
|
|
||||||
*/
|
|
||||||
protected abstract val libraryNameFilter: String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used to split the unified item list into Books vs Audiobooks tabs.
|
|
||||||
*/
|
|
||||||
protected abstract val itemTypeFilter: String?
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(RemoteLibraryState())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
private var cacheStatusJob: Job? = null
|
|
||||||
|
|
||||||
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
|
||||||
val effects = _effects.asSharedFlow()
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadLibraries()
|
|
||||||
viewModelScope.launch {
|
|
||||||
serverStatusMonitor.isOnline.collect { online ->
|
|
||||||
_state.update {
|
|
||||||
if (it.serverOnline == online) it
|
|
||||||
else it.copy(serverOnline = online, offlineOnly = !online || it.offlineOnly)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadLibraries() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
|
||||||
if (!serverSettings.hasCredentials()) {
|
|
||||||
_state.update { it.copy(isLoading = false, error = "Server not configured") }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
val result = fetchLibrariesUseCase()
|
|
||||||
val allLibraries = result.getOrDefault(emptyList())
|
|
||||||
val libraries = allLibraries.filter { matchesLibraryFilter(it) }
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
libraries = libraries,
|
|
||||||
error = result.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (libraries.isNotEmpty() && state.value.selectedLibrary == null) {
|
|
||||||
selectLibrary(libraries.first())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Matches library to filter by name pattern (e.g. "Books" vs "Audiobooks").
|
|
||||||
* The unified endpoint returns mixed items, so we no longer filter libraries by mediaType.
|
|
||||||
*/
|
|
||||||
private fun matchesLibraryFilter(library: RemoteLibrary): Boolean {
|
|
||||||
val filter = libraryNameFilter ?: return true
|
|
||||||
val name = library.name.lowercase()
|
|
||||||
return when (filter) {
|
|
||||||
"book" -> name.contains("book") && !name.contains("audio")
|
|
||||||
"audiobook" -> name.contains("audio")
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectLibrary(library: RemoteLibrary) {
|
|
||||||
_state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
|
|
||||||
loadBooks(library.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadBooks(libraryId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
|
||||||
val result = fetchBooksUseCase(libraryId)
|
|
||||||
val allBooks = result.getOrDefault(emptyList())
|
|
||||||
val books = if (itemTypeFilter != null) {
|
|
||||||
allBooks.filter { matchesItemTypeFilter(it) }
|
|
||||||
} else {
|
|
||||||
allBooks
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
books = books,
|
|
||||||
error = result.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
observeCacheStatuses()
|
|
||||||
|
|
||||||
if (state.value.serverOnline) {
|
|
||||||
autoCacheEbooks(books)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun autoCacheEbooks(books: List<Book>) {
|
|
||||||
// Auto-cache on tab open is disabled by design. Caching now happens
|
|
||||||
// only when the user explicitly opens a book or presses download.
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun matchesItemTypeFilter(book: Book): Boolean {
|
|
||||||
val filter = itemTypeFilter ?: return true
|
|
||||||
return when (filter) {
|
|
||||||
"book" -> book.itemType == "ebook" || book.itemType == "hybrid"
|
|
||||||
"audiobook" -> book.itemType == "audiobook" || book.itemType == "podcast" || book.itemType == "hybrid"
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cacheBook(book: Book) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(cachingBookId = book.remoteId) }
|
|
||||||
val result = cacheBookUseCase(book)
|
|
||||||
_state.update { it.copy(cachingBookId = null) }
|
|
||||||
if (result.isFailure) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observeCacheStatuses()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteCache(book: Book) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result = deleteCacheUseCase(book.remoteId)
|
|
||||||
if (result.isFailure) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleOfflineOnly() {
|
|
||||||
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun openBook(book: Book) {
|
|
||||||
if (!book.hasEbook || book.remoteId.isBlank()) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(cachingBookId = book.remoteId) }
|
|
||||||
val result = cacheBookUseCase(book)
|
|
||||||
_state.update { it.copy(cachingBookId = null) }
|
|
||||||
|
|
||||||
result
|
|
||||||
.onSuccess { bookId ->
|
|
||||||
if (bookId <= 0) {
|
|
||||||
_state.update { it.copy(error = "Could not open book: invalid id") }
|
|
||||||
return@onSuccess
|
|
||||||
}
|
|
||||||
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
_state.update { it.copy(error = error.message ?: "Could not open book") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts caching an audiobook so it can be played offline.
|
|
||||||
*/
|
|
||||||
fun cacheAudiobook(book: Book) {
|
|
||||||
if (!book.hasAudio || book.remoteId.isBlank()) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(cachingBookId = book.remoteId) }
|
|
||||||
val result = cacheBookUseCase(book)
|
|
||||||
_state.update { it.copy(cachingBookId = null) }
|
|
||||||
if (result.isFailure) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(error = result.exceptionOrNull()?.message ?: "Could not cache audiobook")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observeCacheStatuses()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeCacheStatuses() {
|
|
||||||
cacheStatusJob?.cancel()
|
|
||||||
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
|
||||||
if (remoteIds.isEmpty()) {
|
|
||||||
_state.update { it.copy(cacheStatuses = emptyMap()) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cacheStatusJob =
|
|
||||||
viewModelScope.launch {
|
|
||||||
combine(
|
|
||||||
remoteIds.map { remoteId ->
|
|
||||||
getCacheStatusUseCase(remoteId)
|
|
||||||
},
|
|
||||||
) { statuses ->
|
|
||||||
statuses.toList().associateBy { it.remoteId }
|
|
||||||
}.collect { statuses ->
|
|
||||||
_state.update { it.copy(cacheStatuses = statuses) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,10 +24,13 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
import org.dueattendant149.bookshelf.R
|
import org.dueattendant149.bookshelf.R
|
||||||
import org.dueattendant149.bookshelf.data.settings.SettingsManager
|
import org.dueattendant149.bookshelf.data.settings.SettingsManager
|
||||||
import org.dueattendant149.bookshelf.presentation.bookshelf.BooksScreen
|
import org.dueattendant149.bookshelf.presentation.browse.BrowseModel
|
||||||
import org.dueattendant149.bookshelf.presentation.bookshelf.AudiobooksScreen
|
import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.search.SearchScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryModel
|
||||||
import org.dueattendant149.bookshelf.presentation.settings.SettingsScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.library.LibraryModel
|
||||||
|
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem
|
import org.dueattendant149.bookshelf.presentation.navigator.NavigatorItem
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.StackEvent
|
import org.dueattendant149.bookshelf.presentation.navigator.StackEvent
|
||||||
import org.dueattendant149.bookshelf.presentation.settings.SettingsModel
|
import org.dueattendant149.bookshelf.presentation.settings.SettingsModel
|
||||||
|
|
@ -74,6 +77,11 @@ class MainActivity : AppCompatActivity() {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
|
// Initializing Screen Models
|
||||||
|
val libraryModel = hiltViewModel<LibraryModel>()
|
||||||
|
val historyModel = hiltViewModel<HistoryModel>()
|
||||||
|
val browseModel = hiltViewModel<BrowseModel>()
|
||||||
|
|
||||||
SettingsEffects(
|
SettingsEffects(
|
||||||
effects = settingsModel.effects
|
effects = settingsModel.effects
|
||||||
)
|
)
|
||||||
|
|
@ -81,32 +89,32 @@ class MainActivity : AppCompatActivity() {
|
||||||
ProvideSettings(settings) {
|
ProvideSettings(settings) {
|
||||||
val tabs = persistentListOf(
|
val tabs = persistentListOf(
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = BooksScreen,
|
screen = LibraryScreen,
|
||||||
title = R.string.books_screen,
|
title = R.string.library_screen,
|
||||||
tooltip = R.string.books_content_desc,
|
tooltip = R.string.library_content_desc,
|
||||||
selectedIcon = R.drawable.book_tab_filled,
|
selectedIcon = R.drawable.library_screen_filled,
|
||||||
unselectedIcon = R.drawable.book_tab_outlined
|
unselectedIcon = R.drawable.library_screen_outlined
|
||||||
),
|
),
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = AudiobooksScreen,
|
screen = HistoryScreen,
|
||||||
title = R.string.audiobooks_screen,
|
title = R.string.history_screen,
|
||||||
tooltip = R.string.audiobooks_content_desc,
|
tooltip = R.string.history_content_desc,
|
||||||
selectedIcon = R.drawable.audiobook_tab_filled,
|
selectedIcon = R.drawable.history_screen_filled,
|
||||||
unselectedIcon = R.drawable.audiobook_tab_outlined
|
unselectedIcon = R.drawable.history_screen_outlined
|
||||||
),
|
),
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = SearchScreen,
|
screen = BrowseScreen,
|
||||||
title = R.string.search_screen,
|
title = R.string.browse_screen,
|
||||||
tooltip = R.string.search_content_desc_network,
|
tooltip = R.string.browse_content_desc,
|
||||||
selectedIcon = R.drawable.search_tab_filled,
|
selectedIcon = R.drawable.browse_screen_filled,
|
||||||
unselectedIcon = R.drawable.search_tab_outlined
|
unselectedIcon = R.drawable.browse_screen_outlined
|
||||||
),
|
),
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = SettingsScreen,
|
screen = RemoteLibraryScreen,
|
||||||
title = R.string.settings_screen,
|
title = R.string.bookshelf_screen,
|
||||||
tooltip = R.string.settings_screen,
|
tooltip = R.string.bookshelf_content_desc,
|
||||||
selectedIcon = R.drawable.settings_tab_filled,
|
selectedIcon = R.drawable.bookshelf_screen_filled,
|
||||||
unselectedIcon = R.drawable.settings_tab_outlined
|
unselectedIcon = R.drawable.bookshelf_screen_outlined
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -121,7 +129,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
) {
|
) {
|
||||||
Navigator(
|
Navigator(
|
||||||
initialScreen = if (settings.showStartScreen.value) StartScreen
|
initialScreen = if (settings.showStartScreen.value) StartScreen
|
||||||
else BooksScreen,
|
else LibraryScreen,
|
||||||
transitionSpec = { lastEvent ->
|
transitionSpec = { lastEvent ->
|
||||||
when (lastEvent) {
|
when (lastEvent) {
|
||||||
StackEvent.DEFAULT -> {
|
StackEvent.DEFAULT -> {
|
||||||
|
|
@ -137,14 +145,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
},
|
},
|
||||||
contentKey = {
|
contentKey = {
|
||||||
when (it) {
|
when (it) {
|
||||||
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs"
|
LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs"
|
||||||
else -> it
|
else -> it
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
backHandlerEnabled = { it != StartScreen }
|
backHandlerEnabled = { it != StartScreen }
|
||||||
) { screen ->
|
) { screen ->
|
||||||
when (screen) {
|
when (screen) {
|
||||||
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> {
|
LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> {
|
||||||
NavigatorTabs(
|
NavigatorTabs(
|
||||||
currentTab = screen,
|
currentTab = screen,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,7 @@ class Navigator @AssistedInject constructor(
|
||||||
popping: Boolean = false,
|
popping: Boolean = false,
|
||||||
saveInBackStack: Boolean = true
|
saveInBackStack: Boolean = true
|
||||||
) {
|
) {
|
||||||
val current = lastItem.value
|
if (lastItem.value::class == targetScreen::class) return
|
||||||
if (current::class == targetScreen::class && current == targetScreen) return
|
|
||||||
if (!saveInBackStack) items.removeLast()
|
if (!saveInBackStack) items.removeLast()
|
||||||
|
|
||||||
changeStackEvent(
|
changeStackEvent(
|
||||||
|
|
@ -61,6 +60,7 @@ class Navigator @AssistedInject constructor(
|
||||||
else StackEvent.DEFAULT
|
else StackEvent.DEFAULT
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (lastItem.value::class == targetScreen::class) items.removeLast()
|
||||||
items.add(targetScreen)
|
items.add(targetScreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.presentation.player
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class ChapterListScreen(val book: Book) : Screen, Parcelable {
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<PlayerModel>()
|
|
||||||
val state = model.state.collectAsStateWithLifecycle().value
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(book.title) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.pop() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.go_back_content_desc)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(state.tracks) { index, track ->
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
navigator.push(PlayerScreen(book, startTrackIndex = index))
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "${index + 1}. ${track.title}",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatMs(track.durationMs),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatMs(ms: Long): String {
|
|
||||||
val totalSeconds = ms / 1000
|
|
||||||
val hours = totalSeconds / 3600
|
|
||||||
val minutes = (totalSeconds % 3600) / 60
|
|
||||||
val seconds = totalSeconds % 60
|
|
||||||
return if (hours > 0) {
|
|
||||||
String.format("%d:%02d:%02d", hours, minutes, seconds)
|
|
||||||
} else {
|
|
||||||
String.format("%02d:%02d", minutes, seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.player
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.PlaybackException
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.datasource.cache.CacheDataSource
|
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
import androidx.media3.exoplayer.LoadControl
|
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
|
||||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
|
||||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
|
||||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
|
||||||
import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
|
|
||||||
import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class PlayerModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val serverSettings: ServerSettings,
|
|
||||||
private val audiobookshelfRepository: AudiobookshelfRepository,
|
|
||||||
private val database: BookDatabase,
|
|
||||||
private val bookMapper: BookMapper,
|
|
||||||
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
|
|
||||||
private val cacheBookUseCase: CacheBookUseCase,
|
|
||||||
private val okHttpClient: okhttp3.OkHttpClient,
|
|
||||||
private val authProvider: PlaybackAuthProvider,
|
|
||||||
private val simpleCache: SimpleCache,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
val exoPlayer: ExoPlayer by lazy {
|
|
||||||
val authClient = okHttpClient.newBuilder()
|
|
||||||
.addInterceptor(AuthorizationInterceptor(authProvider::token))
|
|
||||||
.build()
|
|
||||||
val upstreamFactory = OkHttpDataSource.Factory(authClient)
|
|
||||||
val cacheDataSourceFactory = CacheDataSource.Factory()
|
|
||||||
.setCache(simpleCache)
|
|
||||||
.setUpstreamDataSourceFactory(upstreamFactory)
|
|
||||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
|
||||||
ExoPlayer.Builder(context)
|
|
||||||
.setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true))
|
|
||||||
.setTrackSelector(DefaultTrackSelector(context))
|
|
||||||
.setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
|
|
||||||
.setLoadControl(createLoadControl())
|
|
||||||
.build()
|
|
||||||
.also { it.addListener(listener) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createLoadControl(): LoadControl {
|
|
||||||
return androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
|
||||||
.setAllocator(DefaultAllocator(true, 16 * 1024))
|
|
||||||
.setBufferDurationsMs(
|
|
||||||
/* minBufferMs = */ 30_000,
|
|
||||||
/* maxBufferMs = */ 120_000,
|
|
||||||
/* bufferForPlaybackMs = */ 2_500,
|
|
||||||
/* bufferForPlaybackAfterRebufferMs = */ 5_000,
|
|
||||||
)
|
|
||||||
.setPrioritizeTimeOverSizeThresholds(true)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(PlayerState())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val listener =
|
|
||||||
object : Player.Listener {
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
|
||||||
_state.update { it.copy(isPlaying = isPlaying) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = playbackState == Player.STATE_BUFFERING,
|
|
||||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPositionDiscontinuity(
|
|
||||||
oldPosition: Player.PositionInfo,
|
|
||||||
newPosition: Player.PositionInfo,
|
|
||||||
reason: Int,
|
|
||||||
) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
currentTrackIndex = exoPlayer.currentMediaItemIndex,
|
|
||||||
currentPositionMs = newPosition.positionMs.coerceAtLeast(0L),
|
|
||||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
currentTrackIndex = exoPlayer.currentMediaItemIndex,
|
|
||||||
durationMs = exoPlayer.duration.coerceAtLeast(0L),
|
|
||||||
currentPositionMs = 0L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
|
||||||
_state.update { it.copy(error = error.localizedMessage, isLoading = false) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
startProgressUpdates()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun init(book: Book, startTrackIndex: Int = 0) {
|
|
||||||
if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank() && startTrackIndex == 0) return
|
|
||||||
_state.update { it.copy(book = book, isLoading = true, error = null) }
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
authProvider.refresh(serverSettings)
|
|
||||||
|
|
||||||
val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme()
|
|
||||||
if (baseUrl.isNullOrBlank()) {
|
|
||||||
_state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
|
||||||
tracksResult.fold(
|
|
||||||
onSuccess = { tracks ->
|
|
||||||
if (tracks.isEmpty()) {
|
|
||||||
_state.update { it.copy(isLoading = false, error = "No audio files found") }
|
|
||||||
return@fold
|
|
||||||
}
|
|
||||||
_state.update { it.copy(tracks = tracks) }
|
|
||||||
val startIndex = startTrackIndex.coerceIn(0, tracks.lastIndex)
|
|
||||||
val startPosition = if (startIndex == 0) book.audioCurrentPosition else 0L
|
|
||||||
loadTracks(baseUrl, tracks, startIndex, startPosition)
|
|
||||||
startService(book)
|
|
||||||
},
|
|
||||||
onFailure = { error ->
|
|
||||||
_state.update { it.copy(isLoading = false, error = error.message) }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadTracks(baseUrl: String, tracks: List<AudioTrack>, startIndex: Int = 0, startPositionMs: Long = 0L) {
|
|
||||||
val normalizedBase = baseUrl.trimEnd('/') + "/"
|
|
||||||
val bookId = database.bookDao.findBookByRemoteId(_state.value.book.remoteId)?.id
|
|
||||||
val cachedAudioFiles = bookId?.let { id ->
|
|
||||||
withContext(Dispatchers.IO) { database.audioFileDao.getByBookId(id) }
|
|
||||||
}.orEmpty()
|
|
||||||
|
|
||||||
val mediaItems = tracks.map { track ->
|
|
||||||
val localFile = cachedAudioFiles
|
|
||||||
.firstOrNull { it.fileId == track.fileId }
|
|
||||||
?.localPath
|
|
||||||
?.let { path -> File(path).takeIf { it.exists() } }
|
|
||||||
val uri = localFile?.toURI()?.toString()
|
|
||||||
?: "${normalizedBase}api/v1/books/${_state.value.book.remoteId}/file/${track.fileId}"
|
|
||||||
MediaItem.Builder()
|
|
||||||
.setMediaId(track.fileId)
|
|
||||||
.setUri(uri)
|
|
||||||
.setMediaMetadata(
|
|
||||||
androidx.media3.common.MediaMetadata.Builder()
|
|
||||||
.setTitle(track.title)
|
|
||||||
.setAlbumTitle(_state.value.book.title)
|
|
||||||
.setArtist(_state.value.book.author.getAsString())
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
|
|
||||||
exoPlayer.prepare()
|
|
||||||
exoPlayer.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
private var lastSyncTimeMs: Long = 0L
|
|
||||||
private var lastSavedPositionMs: Long = 0L
|
|
||||||
|
|
||||||
private fun startProgressUpdates() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
if (exoPlayer.isPlaying || exoPlayer.isLoading) {
|
|
||||||
val pos = exoPlayer.currentPosition.coerceAtLeast(0L)
|
|
||||||
val dur = exoPlayer.duration.coerceAtLeast(0L)
|
|
||||||
val trackIndex = exoPlayer.currentMediaItemIndex
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
currentTrackIndex = trackIndex,
|
|
||||||
currentPositionMs = pos,
|
|
||||||
bufferedPositionMs = exoPlayer.bufferedPosition.coerceAtLeast(0L),
|
|
||||||
durationMs = dur,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (now - lastSyncTimeMs >= 30_000L && pos != lastSavedPositionMs) {
|
|
||||||
lastSyncTimeMs = now
|
|
||||||
lastSavedPositionMs = pos
|
|
||||||
persistPlaybackProgress(pos, dur)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun persistPlaybackProgress(positionMs: Long, durationMs: Long) {
|
|
||||||
val book = _state.value.book
|
|
||||||
if (book.remoteId.isBlank()) return
|
|
||||||
val currentFile = _state.value.tracks.getOrNull(exoPlayer.currentMediaItemIndex)?.fileId
|
|
||||||
?: book.audioCurrentFile
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
val updated = book.copy(
|
|
||||||
audioCurrentFile = currentFile,
|
|
||||||
audioCurrentPosition = positionMs,
|
|
||||||
audioDuration = if (durationMs > 0) durationMs else book.audioDuration,
|
|
||||||
)
|
|
||||||
database.bookDao.updateBook(bookMapper.toBookEntity(updated))
|
|
||||||
|
|
||||||
syncPlaybackProgressUseCase(
|
|
||||||
book = updated,
|
|
||||||
currentFile = currentFile,
|
|
||||||
position = positionMs,
|
|
||||||
duration = updated.audioDuration,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startService(book: Book) {
|
|
||||||
val intent = Intent(context, AudioPlaybackService::class.java).apply {
|
|
||||||
putExtra(AudioPlaybackService.EXTRA_BOOK, book)
|
|
||||||
}
|
|
||||||
context.startForegroundService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playPause() {
|
|
||||||
if (exoPlayer.isPlaying) {
|
|
||||||
exoPlayer.pause()
|
|
||||||
} else {
|
|
||||||
exoPlayer.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekTo(positionMs: Long) {
|
|
||||||
exoPlayer.seekTo(positionMs.coerceIn(0L, exoPlayer.duration.coerceAtLeast(0L)))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekForward(ms: Long = 30_000L) {
|
|
||||||
seekTo(exoPlayer.currentPosition + ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekBackward(ms: Long = 10_000L) {
|
|
||||||
seekTo(exoPlayer.currentPosition - ms)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipToNext() {
|
|
||||||
if (exoPlayer.hasNextMediaItem()) {
|
|
||||||
exoPlayer.seekToNextMediaItem()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipToPrevious() {
|
|
||||||
if (exoPlayer.hasPreviousMediaItem()) {
|
|
||||||
exoPlayer.seekToPreviousMediaItem()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSpeed(speed: Float) {
|
|
||||||
val clamped = speed.coerceIn(0.5f, 2.0f)
|
|
||||||
exoPlayer.setPlaybackSpeed(clamped)
|
|
||||||
_state.update { it.copy(playbackSpeed = clamped) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectTrack(index: Int) {
|
|
||||||
if (index in 0 until exoPlayer.mediaItemCount) {
|
|
||||||
exoPlayer.seekToDefaultPosition(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun downloadAudiobook() {
|
|
||||||
val book = _state.value.book
|
|
||||||
if (book.remoteId.isBlank() || !book.hasAudio) return
|
|
||||||
_state.update { it.copy(isCaching = true, cacheProgress = 0f) }
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result = cacheBookUseCase(book)
|
|
||||||
result.onSuccess {
|
|
||||||
// Refresh player with local files now that caching is done.
|
|
||||||
val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme() ?: return@onSuccess
|
|
||||||
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
|
||||||
tracksResult.onSuccess { tracks ->
|
|
||||||
_state.update { it.copy(tracks = tracks) }
|
|
||||||
loadTracks(baseUrl, tracks, exoPlayer.currentMediaItemIndex, exoPlayer.currentPosition)
|
|
||||||
}
|
|
||||||
}.onFailure { error ->
|
|
||||||
_state.update { it.copy(error = error.message, isCaching = false) }
|
|
||||||
}
|
|
||||||
_state.update { it.copy(isCaching = false, cacheProgress = 1f) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
exoPlayer.removeListener(listener)
|
|
||||||
exoPlayer.stop()
|
|
||||||
exoPlayer.clearMediaItems()
|
|
||||||
context.stopService(Intent(context, AudioPlaybackService::class.java))
|
|
||||||
super.onCleared()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.player
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.ui.player.PlayerContent
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class PlayerScreen(
|
|
||||||
val book: Book,
|
|
||||||
val startTrackIndex: Int = 0,
|
|
||||||
) : Screen, Parcelable {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<PlayerModel>()
|
|
||||||
|
|
||||||
LaunchedEffect(book, startTrackIndex) {
|
|
||||||
model.init(book, startTrackIndex)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
// ExoPlayer lifecycle is tied to the ViewModel; no extra cleanup needed here.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigator = org.dueattendant149.bookshelf.ui.navigator.LocalNavigator.current
|
|
||||||
PlayerContent(
|
|
||||||
state = model.state,
|
|
||||||
onPlayPause = model::playPause,
|
|
||||||
onSeek = model::seekTo,
|
|
||||||
onSeekForward = model::seekForward,
|
|
||||||
onSeekBackward = model::seekBackward,
|
|
||||||
onSkipNext = model::skipToNext,
|
|
||||||
onSkipPrevious = model::skipToPrevious,
|
|
||||||
onSetSpeed = model::setSpeed,
|
|
||||||
onSelectTrack = model::selectTrack,
|
|
||||||
onOpenChapters = { navigator.push(ChapterListScreen(book)) },
|
|
||||||
onDownloadAudiobook = model::downloadAudiobook,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.presentation.player
|
|
||||||
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
|
||||||
|
|
||||||
data class PlayerState(
|
|
||||||
val book: Book = Book.default,
|
|
||||||
val tracks: List<AudioTrack> = emptyList(),
|
|
||||||
val currentTrackIndex: Int = 0,
|
|
||||||
val isPlaying: Boolean = false,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val isCaching: Boolean = false,
|
|
||||||
val cacheProgress: Float = 0f,
|
|
||||||
val currentPositionMs: Long = 0L,
|
|
||||||
val durationMs: Long = 0L,
|
|
||||||
val bufferedPositionMs: Long = 0L,
|
|
||||||
val playbackSpeed: Float = 1f,
|
|
||||||
val error: String? = null,
|
|
||||||
)
|
|
||||||
|
|
@ -38,8 +38,4 @@ sealed class ReaderEffect {
|
||||||
data class OnNavigateToBookInfo(
|
data class OnNavigateToBookInfo(
|
||||||
val changePath: Boolean
|
val changePath: Boolean
|
||||||
) : ReaderEffect()
|
) : ReaderEffect()
|
||||||
|
|
||||||
data class OnNavigateToRsvp(
|
|
||||||
val bookId: Int
|
|
||||||
) : ReaderEffect()
|
|
||||||
}
|
}
|
||||||
|
|
@ -40,14 +40,6 @@ sealed class ReaderEvent {
|
||||||
val progress: Float
|
val progress: Float
|
||||||
) : ReaderEvent()
|
) : ReaderEvent()
|
||||||
|
|
||||||
data class OnChangePage(
|
|
||||||
val page: Int
|
|
||||||
) : ReaderEvent()
|
|
||||||
|
|
||||||
data class OnPagesComputed(
|
|
||||||
val pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>
|
|
||||||
) : ReaderEvent()
|
|
||||||
|
|
||||||
data class OnRestoreCheckpoint(
|
data class OnRestoreCheckpoint(
|
||||||
val checkpoint: Checkpoint
|
val checkpoint: Checkpoint
|
||||||
) : ReaderEvent()
|
) : ReaderEvent()
|
||||||
|
|
@ -86,8 +78,4 @@ sealed class ReaderEvent {
|
||||||
data class OnNavigateToBookInfo(
|
data class OnNavigateToBookInfo(
|
||||||
val changePath: Boolean
|
val changePath: Boolean
|
||||||
) : ReaderEvent()
|
) : ReaderEvent()
|
||||||
|
|
||||||
data class OnNavigateToRsvp(
|
|
||||||
val bookId: Int
|
|
||||||
) : ReaderEvent()
|
|
||||||
}
|
}
|
||||||
|
|
@ -37,8 +37,6 @@ import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseC
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
|
|
@ -52,9 +50,7 @@ class ReaderModel @Inject constructor(
|
||||||
private val getTextUseCase: GetTextUseCase,
|
private val getTextUseCase: GetTextUseCase,
|
||||||
private val getBookUseCase: GetBookUseCase,
|
private val getBookUseCase: GetBookUseCase,
|
||||||
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
||||||
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
private val getChapterProgressUseCase: GetChapterProgressUseCase
|
||||||
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
|
||||||
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -98,9 +94,7 @@ class ReaderModel @Inject constructor(
|
||||||
book = it.book.copy(
|
book = it.book.copy(
|
||||||
lastOpened = lastOpened
|
lastOpened = lastOpened
|
||||||
),
|
),
|
||||||
text = text,
|
text = text
|
||||||
pages = emptyList(),
|
|
||||||
currentPage = 0
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ensureActive()
|
ensureActive()
|
||||||
|
|
@ -115,34 +109,24 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
is ReaderEvent.OnRestoreScroll -> {
|
is ReaderEvent.OnRestoreScroll -> {
|
||||||
val state = _state.value
|
snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 }
|
||||||
if (state.pages.isNotEmpty()) {
|
|
||||||
val page = state.pages
|
|
||||||
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
|
|
||||||
.takeIf { it >= 0 }
|
|
||||||
?: 0
|
|
||||||
onEvent(ReaderEvent.OnChangePage(page))
|
|
||||||
_state.update { it.copy(isLoading = false, errorMessage = null) }
|
|
||||||
} else {
|
|
||||||
snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 }
|
|
||||||
|
|
||||||
state.listState.requestScrollToItem(
|
_state.value.listState.requestScrollToItem(
|
||||||
index = state.book.scrollIndex,
|
index = _state.value.book.scrollIndex,
|
||||||
scrollOffset = state.book.scrollOffset
|
scrollOffset = _state.value.book.scrollOffset
|
||||||
|
)
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||||
|
it.book.scrollIndex,
|
||||||
|
it.text
|
||||||
|
)
|
||||||
|
it.copy(
|
||||||
|
currentChapter = currentChapter,
|
||||||
|
currentChapterProgress = currentChapterProgress,
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null
|
||||||
)
|
)
|
||||||
|
|
||||||
_state.update {
|
|
||||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
|
||||||
it.book.scrollIndex,
|
|
||||||
it.text
|
|
||||||
)
|
|
||||||
it.copy(
|
|
||||||
currentChapter = currentChapter,
|
|
||||||
currentChapterProgress = currentChapterProgress,
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,10 +177,6 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
updateBookUseCase(_state.value.book)
|
||||||
syncReadingProgressUseCase(
|
|
||||||
_state.value.book,
|
|
||||||
_state.value.currentChapter?.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
LibraryScreen.refreshListChannel.trySend(300)
|
LibraryScreen.refreshListChannel.trySend(300)
|
||||||
HistoryScreen.refreshListChannel.trySend(300)
|
HistoryScreen.refreshListChannel.trySend(300)
|
||||||
|
|
@ -244,69 +224,14 @@ class ReaderModel @Inject constructor(
|
||||||
scrollJob = viewModelScope.launch(Dispatchers.IO) {
|
scrollJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
delay(300)
|
delay(300)
|
||||||
|
|
||||||
val state = _state.value
|
val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
|
||||||
if (state.pages.isNotEmpty()) {
|
|
||||||
val targetPage = (event.progress * state.pages.lastIndex).roundToInt()
|
|
||||||
onEvent(ReaderEvent.OnChangePage(targetPage))
|
|
||||||
} else {
|
|
||||||
val scrollTo = (state.text.lastIndex * event.progress).roundToInt()
|
|
||||||
state.listState.requestScrollToItem(
|
|
||||||
index = scrollTo,
|
|
||||||
scrollOffset = 0
|
|
||||||
)
|
|
||||||
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderEvent.OnChangePage -> {
|
_state.value.listState.requestScrollToItem(
|
||||||
withContext(Dispatchers.Default) {
|
index = scrollTo,
|
||||||
val state = _state.value
|
scrollOffset = 0
|
||||||
val pages = state.pages
|
|
||||||
if (pages.isEmpty()) return@withContext
|
|
||||||
val page = event.page.coerceIn(0, pages.lastIndex)
|
|
||||||
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
|
|
||||||
|
|
||||||
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
|
||||||
pageStart,
|
|
||||||
state.text
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_state.update {
|
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
|
||||||
it.copy(
|
|
||||||
currentPage = page,
|
|
||||||
currentChapter = currentChapter,
|
|
||||||
currentChapterProgress = currentChapterProgress,
|
|
||||||
book = it.book.copy(
|
|
||||||
progress = calculateProgress(page),
|
|
||||||
scrollIndex = pageStart,
|
|
||||||
scrollOffset = 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
|
||||||
syncReadingProgressUseCase(
|
|
||||||
_state.value.book,
|
|
||||||
_state.value.currentChapter?.title,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ReaderEvent.OnPagesComputed -> {
|
|
||||||
withContext(Dispatchers.Default) {
|
|
||||||
val pages = event.pages
|
|
||||||
if (pages.isEmpty()) {
|
|
||||||
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
// Restore the page that contains the saved scrollIndex.
|
|
||||||
val savedIndex = _state.value.book.scrollIndex
|
|
||||||
val targetPage = pages
|
|
||||||
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
|
|
||||||
.takeIf { it >= 0 }
|
|
||||||
?: 0
|
|
||||||
_state.update { it.copy(pages = pages, currentPage = targetPage) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -361,10 +286,6 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
updateBookUseCase(_state.value.book)
|
||||||
syncReadingProgressUseCase(
|
|
||||||
_state.value.book,
|
|
||||||
_state.value.currentChapter?.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
LibraryScreen.refreshListChannel.trySend(0)
|
LibraryScreen.refreshListChannel.trySend(0)
|
||||||
HistoryScreen.refreshListChannel.trySend(0)
|
HistoryScreen.refreshListChannel.trySend(0)
|
||||||
|
|
@ -453,10 +374,6 @@ class ReaderModel @Inject constructor(
|
||||||
is ReaderEvent.OnNavigateToBookInfo -> {
|
is ReaderEvent.OnNavigateToBookInfo -> {
|
||||||
_effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath))
|
_effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath))
|
||||||
}
|
}
|
||||||
|
|
||||||
is ReaderEvent.OnNavigateToRsvp -> {
|
|
||||||
_effects.emit(ReaderEffect.OnNavigateToRsvp(event.bookId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.also { eventStack.add(it) }
|
}.also { eventStack.add(it) }
|
||||||
}
|
}
|
||||||
|
|
@ -531,10 +448,6 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
updateBookUseCase(_state.value.book)
|
||||||
syncReadingProgressUseCase(
|
|
||||||
_state.value.book,
|
|
||||||
_state.value.currentChapter?.title,
|
|
||||||
)
|
|
||||||
|
|
||||||
LibraryScreen.refreshListChannel.trySend(0)
|
LibraryScreen.refreshListChannel.trySend(0)
|
||||||
HistoryScreen.refreshListChannel.trySend(0)
|
HistoryScreen.refreshListChannel.trySend(0)
|
||||||
|
|
@ -557,31 +470,22 @@ class ReaderModel @Inject constructor(
|
||||||
} ?: (-1 to -1)
|
} ?: (-1 to -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateProgress(pageOrIndex: Int? = null): Float {
|
private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
|
||||||
val state = _state.value
|
|
||||||
if (
|
if (
|
||||||
state.isLoading ||
|
_state.value.isLoading ||
|
||||||
state.text.isEmpty() ||
|
_state.value.listState.layoutInfo.totalItemsCount == 0 ||
|
||||||
state.errorMessage != null
|
_state.value.text.isEmpty() ||
|
||||||
) return state.book.progress
|
_state.value.errorMessage != null
|
||||||
|
) return _state.value.book.progress
|
||||||
|
|
||||||
if (state.pages.isNotEmpty()) {
|
if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f
|
||||||
if (state.pages.size <= 1) return 0f
|
|
||||||
val page = pageOrIndex ?: state.currentPage
|
|
||||||
return page.coerceIn(0, state.pages.lastIndex)
|
|
||||||
.div(state.pages.lastIndex.toFloat())
|
|
||||||
.coerceAndPreventNaN()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress
|
val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index
|
||||||
|
if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f
|
||||||
|
|
||||||
val index = pageOrIndex ?: state.listState.firstVisibleItemIndex
|
return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex)
|
||||||
if (index == 0) return 0f
|
.div(_state.value.text.lastIndex.toFloat())
|
||||||
|
.coerceAndPreventNaN()
|
||||||
val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
|
|
||||||
if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
|
|
||||||
|
|
||||||
return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
|
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ package org.dueattendant149.bookshelf.presentation.reader
|
||||||
|
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import android.view.KeyEvent
|
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
|
@ -24,29 +23,19 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import androidx.compose.foundation.focusable
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.ColorMatrix
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
@ -66,7 +55,6 @@ import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
||||||
import org.dueattendant149.bookshelf.ui.reader.ReaderContent
|
import org.dueattendant149.bookshelf.ui.reader.ReaderContent
|
||||||
import org.dueattendant149.bookshelf.ui.reader.ReaderEffects
|
import org.dueattendant149.bookshelf.ui.reader.ReaderEffects
|
||||||
import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
@ -95,11 +83,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
) {
|
) {
|
||||||
state.value.listState
|
state.value.listState
|
||||||
}
|
}
|
||||||
|
|
||||||
val configuration = LocalConfiguration.current
|
|
||||||
val textMeasurer = rememberTextMeasurer()
|
|
||||||
|
|
||||||
val readerPagination = settings.readerPagination.value
|
|
||||||
val nestedScrollConnection = remember {
|
val nestedScrollConnection = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
object : NestedScrollConnection {
|
object : NestedScrollConnection {
|
||||||
|
|
@ -172,9 +155,76 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
) return@remember 0.sp
|
) return@remember 0.sp
|
||||||
(settings.paragraphIndentation.lastValue * 6).sp
|
(settings.paragraphIndentation.lastValue * 6).sp
|
||||||
}
|
}
|
||||||
|
val perceptionExpanderPadding = remember(
|
||||||
|
sidePadding,
|
||||||
|
settings.perceptionExpanderPadding.value
|
||||||
|
) {
|
||||||
|
sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
|
||||||
|
}
|
||||||
|
val perceptionExpanderThickness = remember(
|
||||||
|
settings.perceptionExpanderThickness.value
|
||||||
|
) {
|
||||||
|
(settings.perceptionExpanderThickness.lastValue * 0.25f).dp
|
||||||
|
}
|
||||||
|
val horizontalLimiterHeight = remember(
|
||||||
|
settings.horizontalLimiterHeight.value
|
||||||
|
) {
|
||||||
|
(settings.horizontalLimiterHeight.lastValue * 20).dp
|
||||||
|
}
|
||||||
|
val horizontalLimiterRulerThickness = remember(
|
||||||
|
settings.horizontalLimiterRulerThickness.value
|
||||||
|
) {
|
||||||
|
(settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
|
||||||
|
}
|
||||||
|
val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
|
||||||
|
(36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
|
||||||
|
}
|
||||||
|
val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
|
||||||
|
when (settings.highlightedReadingThickness.lastValue) {
|
||||||
|
2 -> FontWeight.SemiBold
|
||||||
|
3 -> FontWeight.Bold
|
||||||
|
else -> FontWeight.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val horizontalAlignment = remember(settings.textAlignment.value) {
|
||||||
|
when (settings.textAlignment.lastValue) {
|
||||||
|
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
|
||||||
|
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
|
||||||
|
ReaderTextAlignment.END -> Alignment.End
|
||||||
|
}
|
||||||
|
}
|
||||||
val imagesWidth = remember(settings.imagesWidth.value) {
|
val imagesWidth = remember(settings.imagesWidth.value) {
|
||||||
settings.imagesWidth.lastValue.coerceAtLeast(0.01f)
|
settings.imagesWidth.lastValue.coerceAtLeast(0.01f)
|
||||||
}
|
}
|
||||||
|
val imagesCornersRoundness = remember(
|
||||||
|
settings.imagesCornersRoundness.value,
|
||||||
|
settings.imagesWidth.value
|
||||||
|
) {
|
||||||
|
(settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
|
||||||
|
}
|
||||||
|
val imagesColorEffects = remember(
|
||||||
|
settings.imagesColorEffects.value,
|
||||||
|
fontColor.value,
|
||||||
|
backgroundColor.value
|
||||||
|
) {
|
||||||
|
when (settings.imagesColorEffects.lastValue) {
|
||||||
|
ReaderColorEffects.OFF -> null
|
||||||
|
|
||||||
|
ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
|
||||||
|
ColorMatrix().apply { setToSaturation(0f) }
|
||||||
|
)
|
||||||
|
|
||||||
|
ReaderColorEffects.FONT -> ColorFilter.tint(
|
||||||
|
color = fontColor.value,
|
||||||
|
blendMode = BlendMode.Color
|
||||||
|
)
|
||||||
|
|
||||||
|
ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
|
||||||
|
color = backgroundColor.value,
|
||||||
|
blendMode = BlendMode.Color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val progressBarPadding = remember(settings.progressBarPadding.value) {
|
val progressBarPadding = remember(settings.progressBarPadding.value) {
|
||||||
(settings.progressBarPadding.lastValue * 3).dp
|
(settings.progressBarPadding.lastValue * 3).dp
|
||||||
}
|
}
|
||||||
|
|
@ -237,158 +287,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recompute pages when text or relevant settings change.
|
|
||||||
val pages by produceState(
|
|
||||||
initialValue = emptyList<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>(),
|
|
||||||
state.value.text,
|
|
||||||
readerPagination,
|
|
||||||
settings.fontSize.value,
|
|
||||||
settings.lineHeight.value,
|
|
||||||
settings.sidePadding.value,
|
|
||||||
settings.paragraphHeight.value,
|
|
||||||
settings.textAlignment.value,
|
|
||||||
settings.fontFamily.value,
|
|
||||||
settings.fontThickness.value,
|
|
||||||
settings.italic.value,
|
|
||||||
settings.letterSpacing.value,
|
|
||||||
settings.paragraphIndentation.value,
|
|
||||||
settings.progressBarPadding.value,
|
|
||||||
settings.progressBarFontSize.value,
|
|
||||||
settings.imagesWidth.value,
|
|
||||||
settings.fullscreen.value,
|
|
||||||
settings.cutoutPadding.value,
|
|
||||||
configuration.screenWidthDp,
|
|
||||||
configuration.screenHeightDp,
|
|
||||||
) {
|
|
||||||
value = withContext(Dispatchers.Default) {
|
|
||||||
if (!readerPagination || state.value.text.isEmpty()) {
|
|
||||||
emptyList()
|
|
||||||
} else {
|
|
||||||
val contentPaddingTopPx = with(density) {
|
|
||||||
contentPadding.calculateTopPadding().toPx()
|
|
||||||
}.toInt()
|
|
||||||
val contentPaddingBottomPx = with(density) {
|
|
||||||
contentPadding.calculateBottomPadding().toPx()
|
|
||||||
}.toInt()
|
|
||||||
val contentPaddingStartPx = with(density) {
|
|
||||||
contentPadding.calculateStartPadding(layoutDirection).toPx()
|
|
||||||
}.toInt()
|
|
||||||
val contentPaddingEndPx = with(density) {
|
|
||||||
contentPadding.calculateEndPadding(layoutDirection).toPx()
|
|
||||||
}.toInt()
|
|
||||||
|
|
||||||
val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
|
|
||||||
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
|
|
||||||
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
|
|
||||||
|
|
||||||
val contentWidthPx = with(density) {
|
|
||||||
configuration.screenWidthDp.dp.toPx()
|
|
||||||
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
|
|
||||||
|
|
||||||
val progressBarHeightPx = with(density) {
|
|
||||||
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
val contentHeightPx = with(density) {
|
|
||||||
configuration.screenHeightDp.dp.toPx()
|
|
||||||
}.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx
|
|
||||||
|
|
||||||
val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt()
|
|
||||||
|
|
||||||
val style = readerParagraphTextStyle(
|
|
||||||
fontFamily = settings.fontFamily.lastValue,
|
|
||||||
fontThickness = settings.fontThickness.lastValue,
|
|
||||||
fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal,
|
|
||||||
textAlignment = settings.textAlignment.lastValue,
|
|
||||||
fontSize = settings.fontSize.lastValue.sp,
|
|
||||||
lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp,
|
|
||||||
letterSpacing = (settings.letterSpacing.lastValue / 100f).em,
|
|
||||||
paragraphIndentation = paragraphIndentation,
|
|
||||||
)
|
|
||||||
org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke(
|
|
||||||
text = state.value.text,
|
|
||||||
contentWidthPx = contentWidthPx,
|
|
||||||
contentHeightPx = contentHeightPx,
|
|
||||||
paragraphStyle = style,
|
|
||||||
chapterTitleAlignment = settings.chapterTitleAlignment.lastValue,
|
|
||||||
paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(),
|
|
||||||
textMeasurer = textMeasurer,
|
|
||||||
imageMaxWidthPx = imageMaxWidthPx,
|
|
||||||
chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(),
|
|
||||||
separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val perceptionExpanderPadding = remember(
|
|
||||||
sidePadding,
|
|
||||||
settings.perceptionExpanderPadding.value
|
|
||||||
) {
|
|
||||||
sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
|
|
||||||
}
|
|
||||||
val perceptionExpanderThickness = remember(
|
|
||||||
settings.perceptionExpanderThickness.value
|
|
||||||
) {
|
|
||||||
(settings.perceptionExpanderThickness.lastValue * 0.25f).dp
|
|
||||||
}
|
|
||||||
val horizontalLimiterHeight = remember(
|
|
||||||
settings.horizontalLimiterHeight.value
|
|
||||||
) {
|
|
||||||
(settings.horizontalLimiterHeight.lastValue * 20).dp
|
|
||||||
}
|
|
||||||
val horizontalLimiterRulerThickness = remember(
|
|
||||||
settings.horizontalLimiterRulerThickness.value
|
|
||||||
) {
|
|
||||||
(settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
|
|
||||||
}
|
|
||||||
val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
|
|
||||||
(36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
|
|
||||||
}
|
|
||||||
val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
|
|
||||||
when (settings.highlightedReadingThickness.lastValue) {
|
|
||||||
2 -> FontWeight.SemiBold
|
|
||||||
3 -> FontWeight.Bold
|
|
||||||
else -> FontWeight.Medium
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val horizontalAlignment = remember(settings.textAlignment.value) {
|
|
||||||
when (settings.textAlignment.lastValue) {
|
|
||||||
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
|
|
||||||
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
|
|
||||||
ReaderTextAlignment.END -> Alignment.End
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val imagesCornersRoundness = remember(
|
|
||||||
settings.imagesCornersRoundness.value,
|
|
||||||
settings.imagesWidth.value
|
|
||||||
) {
|
|
||||||
(settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
|
|
||||||
}
|
|
||||||
val imagesColorEffects = remember(
|
|
||||||
settings.imagesColorEffects.value,
|
|
||||||
fontColor.value,
|
|
||||||
backgroundColor.value
|
|
||||||
) {
|
|
||||||
when (settings.imagesColorEffects.lastValue) {
|
|
||||||
ReaderColorEffects.OFF -> null
|
|
||||||
|
|
||||||
ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
|
|
||||||
ColorMatrix().apply { setToSaturation(0f) }
|
|
||||||
)
|
|
||||||
|
|
||||||
ReaderColorEffects.FONT -> ColorFilter.tint(
|
|
||||||
color = fontColor.value,
|
|
||||||
blendMode = BlendMode.Color
|
|
||||||
)
|
|
||||||
|
|
||||||
ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
|
|
||||||
color = backgroundColor.value,
|
|
||||||
blendMode = BlendMode.Color
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val bottomBarPadding = remember(settings.bottomBarPadding.value) {
|
val bottomBarPadding = remember(settings.bottomBarPadding.value) {
|
||||||
(settings.bottomBarPadding.lastValue * 4f).dp
|
(settings.bottomBarPadding.lastValue * 4f).dp
|
||||||
}
|
}
|
||||||
|
|
@ -438,9 +336,6 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
screenModel.init(bookId = bookId)
|
screenModel.init(bookId = bookId)
|
||||||
}
|
}
|
||||||
LaunchedEffect(pages) {
|
|
||||||
screenModel.onEvent(ReaderEvent.OnPagesComputed(pages))
|
|
||||||
}
|
|
||||||
LaunchedEffect(settings.fullscreen.value) {
|
LaunchedEffect(settings.fullscreen.value) {
|
||||||
screenModel.onEvent(
|
screenModel.onEvent(
|
||||||
ReaderEvent.OnMenuVisibility(
|
ReaderEvent.OnMenuVisibility(
|
||||||
|
|
@ -477,45 +372,11 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(readerPagination, state.value.pages, state.value.currentPage) {
|
|
||||||
if (!readerPagination || state.value.pages.isEmpty()) {
|
|
||||||
onDispose { }
|
|
||||||
} else {
|
|
||||||
val pages = state.value.pages
|
|
||||||
val currentRef = java.util.concurrent.atomic.AtomicInteger(state.value.currentPage)
|
|
||||||
val originalCallback = activity.window.callback
|
|
||||||
val proxy = org.dueattendant149.bookshelf.ui.reader.VolumeKeyWindowCallback(
|
|
||||||
delegate = originalCallback,
|
|
||||||
onKeyEvent = { event ->
|
|
||||||
if (event.action == KeyEvent.ACTION_DOWN) {
|
|
||||||
val current = currentRef.get()
|
|
||||||
val target = when (event.keyCode) {
|
|
||||||
KeyEvent.KEYCODE_VOLUME_UP -> if (current > 0) current - 1 else -1
|
|
||||||
KeyEvent.KEYCODE_VOLUME_DOWN -> if (current < pages.lastIndex) current + 1 else -1
|
|
||||||
else -> -1
|
|
||||||
}
|
|
||||||
if (target >= 0) {
|
|
||||||
currentRef.set(target)
|
|
||||||
screenModel.onEvent(ReaderEvent.OnChangePage(target))
|
|
||||||
return@VolumeKeyWindowCallback true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
activity.window.callback = proxy
|
|
||||||
|
|
||||||
onDispose {
|
|
||||||
activity.window.callback = originalCallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
screenModel.clearAsync()
|
screenModel.clearAsync()
|
||||||
|
|
@ -532,44 +393,32 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
fullscreen = settings.fullscreen.value
|
fullscreen = settings.fullscreen.value
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
ReaderContent(
|
||||||
modifier = Modifier
|
book = state.value.book,
|
||||||
.focusable()
|
text = state.value.text,
|
||||||
) {
|
bottomSheet = state.value.bottomSheet,
|
||||||
ReaderContent(
|
drawer = state.value.drawer,
|
||||||
book = state.value.book,
|
listState = listState,
|
||||||
text = state.value.text,
|
currentChapter = state.value.currentChapter,
|
||||||
bottomSheet = state.value.bottomSheet,
|
nestedScrollConnection = nestedScrollConnection.value,
|
||||||
drawer = state.value.drawer,
|
fastColorPresetChange = settings.fastColorPresetChange.value,
|
||||||
listState = listState,
|
perceptionExpander = settings.perceptionExpander.value,
|
||||||
currentChapter = state.value.currentChapter,
|
perceptionExpanderPadding = perceptionExpanderPadding,
|
||||||
nestedScrollConnection = nestedScrollConnection.value,
|
perceptionExpanderThickness = perceptionExpanderThickness,
|
||||||
fastColorPresetChange = settings.fastColorPresetChange.value,
|
horizontalLimiter = settings.horizontalLimiter.value,
|
||||||
perceptionExpander = settings.perceptionExpander.value,
|
horizontalLimiterHeight = horizontalLimiterHeight,
|
||||||
perceptionExpanderPadding = perceptionExpanderPadding,
|
horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
|
||||||
perceptionExpanderThickness = perceptionExpanderThickness,
|
horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
|
||||||
horizontalLimiter = settings.horizontalLimiter.value,
|
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
||||||
horizontalLimiterHeight = horizontalLimiterHeight,
|
horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
|
||||||
horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
|
currentChapterProgress = state.value.currentChapterProgress,
|
||||||
horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
|
isLoading = state.value.isLoading,
|
||||||
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
errorMessage = state.value.errorMessage,
|
||||||
horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
|
checkpoints = state.value.checkpoints,
|
||||||
currentChapterProgress = state.value.currentChapterProgress,
|
showMenu = state.value.showMenu,
|
||||||
isLoading = state.value.isLoading,
|
lockMenu = state.value.lockMenu,
|
||||||
errorMessage = state.value.errorMessage,
|
contentPadding = contentPadding,
|
||||||
checkpoints = state.value.checkpoints,
|
verticalPadding = verticalPadding,
|
||||||
showMenu = state.value.showMenu,
|
|
||||||
lockMenu = state.value.lockMenu,
|
|
||||||
readerPagination = readerPagination,
|
|
||||||
pages = state.value.pages,
|
|
||||||
currentPage = state.value.currentPage,
|
|
||||||
onPageChanged = { page ->
|
|
||||||
if (state.value.pages.isNotEmpty()) {
|
|
||||||
screenModel.onEvent(ReaderEvent.OnChangePage(page))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentPadding = contentPadding,
|
|
||||||
verticalPadding = verticalPadding,
|
|
||||||
horizontalGesture = settings.horizontalGesture.value,
|
horizontalGesture = settings.horizontalGesture.value,
|
||||||
horizontalGestureScroll = settings.horizontalGestureScroll.value,
|
horizontalGestureScroll = settings.horizontalGestureScroll.value,
|
||||||
horizontalGestureSensitivity = horizontalGestureSensitivity,
|
horizontalGestureSensitivity = horizontalGestureSensitivity,
|
||||||
|
|
@ -621,9 +470,7 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
showChaptersDrawer = screenModel::onEvent,
|
showChaptersDrawer = screenModel::onEvent,
|
||||||
dismissDrawer = screenModel::onEvent,
|
dismissDrawer = screenModel::onEvent,
|
||||||
navigateBack = screenModel::onEvent,
|
navigateBack = screenModel::onEvent,
|
||||||
navigateToBookInfo = screenModel::onEvent,
|
navigateToBookInfo = screenModel::onEvent
|
||||||
navigateToRsvp = screenModel::onEvent
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -12,7 +12,6 @@ import org.dueattendant149.bookshelf.core.BottomSheet
|
||||||
import org.dueattendant149.bookshelf.core.Drawer
|
import org.dueattendant149.bookshelf.core.Drawer
|
||||||
import org.dueattendant149.bookshelf.core.ui.UIText
|
import org.dueattendant149.bookshelf.core.ui.UIText
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Chapter
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
|
|
@ -21,8 +20,6 @@ import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
data class ReaderState(
|
data class ReaderState(
|
||||||
val book: Book = Book.default,
|
val book: Book = Book.default,
|
||||||
val text: List<ReaderText> = emptyList(),
|
val text: List<ReaderText> = emptyList(),
|
||||||
val pages: List<ReaderPage> = emptyList(),
|
|
||||||
val currentPage: Int = 0,
|
|
||||||
val listState: LazyListState = LazyListState(),
|
val listState: LazyListState = LazyListState(),
|
||||||
|
|
||||||
val currentChapter: Chapter? = null,
|
val currentChapter: Chapter? = null,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
|
||||||
|
|
||||||
sealed class RsvpEffect {
|
|
||||||
data object OnNavigateBack : RsvpEffect()
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
|
||||||
|
|
||||||
sealed class RsvpEvent {
|
|
||||||
data object OnPlayPause : RsvpEvent()
|
|
||||||
data object OnStop : RsvpEvent()
|
|
||||||
data object OnClose : RsvpEvent()
|
|
||||||
data class OnWpmChange(val wpm: Int) : RsvpEvent()
|
|
||||||
data class OnSeekToProgress(val progress: Float) : RsvpEvent()
|
|
||||||
data class OnSkip(val delta: Int) : RsvpEvent()
|
|
||||||
data object OnToggleControls : RsvpEvent()
|
|
||||||
}
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.dueattendant149.bookshelf.data.settings.SettingsManager
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpEngine
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpTokenizer
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class RsvpModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val getBookUseCase: GetBookUseCase,
|
|
||||||
private val getTextUseCase: GetTextUseCase,
|
|
||||||
private val updateBookUseCase: UpdateBookUseCase,
|
|
||||||
private val settings: SettingsManager,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val engine = RsvpEngine(viewModelScope)
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(RsvpState())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val _effects = MutableSharedFlow<RsvpEffect>()
|
|
||||||
val effects = _effects.asSharedFlow()
|
|
||||||
|
|
||||||
val engineState = engine.state
|
|
||||||
|
|
||||||
fun init(bookId: Int) {
|
|
||||||
viewModelScope.launch(Dispatchers.Default) {
|
|
||||||
val book = getBookUseCase(bookId)
|
|
||||||
if (book == null) {
|
|
||||||
_state.update { it.copy(isLoading = false, errorMessage = "Book not found") }
|
|
||||||
_effects.emit(RsvpEffect.OnNavigateBack)
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update { it.copy(book = book, isLoading = true, errorMessage = null) }
|
|
||||||
|
|
||||||
val text = getTextUseCase(bookId)
|
|
||||||
if (text.isEmpty()) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = "Текст недоступен"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val tokens = withContext(Dispatchers.Default) {
|
|
||||||
RsvpTokenizer.tokenizeReaderText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tokens.isEmpty()) {
|
|
||||||
_state.update {
|
|
||||||
it.copy(isLoading = false, errorMessage = "Книга пуста")
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
val startWpm = settings.rsvpWpm.lastValue.takeIf { it in 50..1500 } ?: 350
|
|
||||||
val pauseOnParagraph = settings.rsvpPauseOnParagraph.lastValue
|
|
||||||
val pauseOnChapter = settings.rsvpPauseOnChapter.lastValue
|
|
||||||
val pauseOnLong = settings.rsvpPauseOnLongWords.lastValue
|
|
||||||
|
|
||||||
engine.setWpm(startWpm)
|
|
||||||
engine.setPauseOnParagraphEnd(pauseOnParagraph)
|
|
||||||
engine.setPauseOnChapterEnd(pauseOnChapter)
|
|
||||||
engine.setPauseOnLongWords(pauseOnLong)
|
|
||||||
engine.setTokens(tokens)
|
|
||||||
|
|
||||||
val startIndex = (book.progress * (tokens.lastIndex.coerceAtLeast(1)))
|
|
||||||
.roundToInt()
|
|
||||||
.coerceIn(0, tokens.lastIndex)
|
|
||||||
engine.seekToIndex(startIndex)
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
book = book,
|
|
||||||
isLoading = false,
|
|
||||||
errorMessage = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onEvent(event: RsvpEvent) {
|
|
||||||
when (event) {
|
|
||||||
RsvpEvent.OnPlayPause -> engine.toggle()
|
|
||||||
RsvpEvent.OnStop -> engine.stop()
|
|
||||||
RsvpEvent.OnClose -> {
|
|
||||||
persistProgressAndClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
is RsvpEvent.OnWpmChange -> {
|
|
||||||
val clamped = event.wpm.coerceIn(50, 1500)
|
|
||||||
engine.setWpm(clamped)
|
|
||||||
settings.rsvpWpm.update(clamped)
|
|
||||||
}
|
|
||||||
|
|
||||||
is RsvpEvent.OnSeekToProgress -> {
|
|
||||||
engine.seekToProgress(event.progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
is RsvpEvent.OnSkip -> {
|
|
||||||
if (event.delta >= 0) engine.skipForward(event.delta)
|
|
||||||
else engine.skipBackward(-event.delta)
|
|
||||||
}
|
|
||||||
|
|
||||||
RsvpEvent.OnToggleControls -> {
|
|
||||||
_state.update { it.copy(showControls = !it.showControls) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun persistProgressAndClose() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val es = engine.state.value
|
|
||||||
val currentBook = state.value.book
|
|
||||||
if (es.tokens.isNotEmpty() && currentBook.id != 0) {
|
|
||||||
val newProgress = es.currentIndex.toFloat() /
|
|
||||||
es.tokens.lastIndex.coerceAtLeast(1).toFloat()
|
|
||||||
updateBookUseCase(
|
|
||||||
currentBook.copy(
|
|
||||||
progress = newProgress.coerceIn(0f, 1f),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
engine.stop()
|
|
||||||
_effects.emit(RsvpEffect.OnNavigateBack)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
engine.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
import org.dueattendant149.bookshelf.ui.rsvp.RsvpContent
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class RsvpScreen(val bookId: Int) : Screen, Parcelable {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<RsvpModel>()
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
|
|
||||||
LaunchedEffect(bookId) {
|
|
||||||
model.init(bookId)
|
|
||||||
}
|
|
||||||
LaunchedEffect(model.effects, navigator) {
|
|
||||||
model.effects.collectLatest { effect ->
|
|
||||||
when (effect) {
|
|
||||||
RsvpEffect.OnNavigateBack -> navigator.pop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RsvpContent(model = model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.rsvp
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
data class RsvpState(
|
|
||||||
val book: Book = Book.default,
|
|
||||||
val isLoading: Boolean = true,
|
|
||||||
val errorMessage: String? = null,
|
|
||||||
val showControls: Boolean = true,
|
|
||||||
)
|
|
||||||
|
|
@ -1,146 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.search
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.GlobalSearchUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.ListDownloadsUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.StartDownloadUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.UploadBookUseCase
|
|
||||||
import java.io.File
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
data class SearchState(
|
|
||||||
val query: String = "",
|
|
||||||
val results: List<SearchResultItemResponse> = emptyList(),
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val downloadingTitle: String? = null,
|
|
||||||
val downloadSuccess: String? = null,
|
|
||||||
val downloadProgresses: Map<String, DownloadStatusResponse> = emptyMap(),
|
|
||||||
val uploadSuccess: String? = null,
|
|
||||||
val isUploading: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class SearchModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val globalSearchUseCase: GlobalSearchUseCase,
|
|
||||||
private val startDownloadUseCase: StartDownloadUseCase,
|
|
||||||
private val listDownloadsUseCase: ListDownloadsUseCase,
|
|
||||||
private val uploadBookUseCase: UploadBookUseCase,
|
|
||||||
) : ViewModel() {
|
|
||||||
private val _state = MutableStateFlow(SearchState())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var pollJob: Job? = null
|
|
||||||
|
|
||||||
fun setQuery(query: String) {
|
|
||||||
_state.update { it.copy(query = query) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun search() {
|
|
||||||
val query = state.value.query.trim()
|
|
||||||
if (query.isBlank()) return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isLoading = true, error = null, results = emptyList()) }
|
|
||||||
val result = globalSearchUseCase(SearchRequest(query = query))
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
results = result.getOrDefault(SearchResponse(emptyList())).results,
|
|
||||||
error = result.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startDownload(item: SearchResultItemResponse) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(downloadingTitle = item.title, downloadSuccess = null) }
|
|
||||||
val request = DownloadRequest(
|
|
||||||
source = item.source,
|
|
||||||
title = item.title,
|
|
||||||
author = item.author,
|
|
||||||
downloadUrl = item.downloadUrl.ifBlank { null },
|
|
||||||
magnetUrl = item.magnetUrl.ifBlank { null },
|
|
||||||
infoHash = item.infoHash.ifBlank { null },
|
|
||||||
md5 = item.md5.ifBlank { null },
|
|
||||||
url = item.url.ifBlank { null },
|
|
||||||
mediaType = item.mediaType.ifBlank { "ebook" },
|
|
||||||
downloadProtocol = item.downloadProtocol,
|
|
||||||
)
|
|
||||||
val result = startDownloadUseCase(request)
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
downloadingTitle = null,
|
|
||||||
downloadSuccess = if (result.isSuccess) item.title else null,
|
|
||||||
error = result.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (result.isSuccess) {
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun uploadBook(file: File, bookType: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isUploading = true, uploadSuccess = null, error = null) }
|
|
||||||
val result = uploadBookUseCase(file, bookType)
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isUploading = false,
|
|
||||||
uploadSuccess = result.getOrNull(),
|
|
||||||
error = result.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearUploadSuccess() {
|
|
||||||
_state.update { it.copy(uploadSuccess = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearDownloadSuccess() {
|
|
||||||
_state.update { it.copy(downloadSuccess = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPolling() {
|
|
||||||
pollJob?.cancel()
|
|
||||||
pollJob = viewModelScope.launch {
|
|
||||||
while (true) {
|
|
||||||
delay(3000L)
|
|
||||||
val result = listDownloadsUseCase()
|
|
||||||
result.onSuccess { list ->
|
|
||||||
val map = list.downloads.associateBy { it.title }
|
|
||||||
_state.update { it.copy(downloadProgresses = map) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
pollJob?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.search
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.ui.search.SearchContent
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
object SearchScreen : Screen, Parcelable {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<SearchModel>()
|
|
||||||
SearchContent(model = model)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,8 +18,6 @@ import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl
|
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl
|
||||||
import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
|
|
||||||
import org.dueattendant149.bookshelf.domain.util.normalizeUri
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class BookshelfSettingsState(
|
data class BookshelfSettingsState(
|
||||||
|
|
@ -110,20 +108,8 @@ class BookshelfSettingsModel
|
||||||
fun save() {
|
fun save() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = _state.value
|
val current = _state.value
|
||||||
val normalizedBookshelfUrl = current.bookshelfUrl.trim().let {
|
serverSettings.setBookshelfUrl(current.bookshelfUrl)
|
||||||
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
|
serverSettings.setAbsUrl(current.absUrl)
|
||||||
}
|
|
||||||
val normalizedAbsUrl = current.absUrl.trim().let {
|
|
||||||
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
|
|
||||||
}
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
bookshelfUrl = normalizedBookshelfUrl,
|
|
||||||
absUrl = normalizedAbsUrl,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
serverSettings.setBookshelfUrl(normalizedBookshelfUrl)
|
|
||||||
serverSettings.setAbsUrl(normalizedAbsUrl)
|
|
||||||
serverSettings.setAbsToken(current.absToken)
|
serverSettings.setAbsToken(current.absToken)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import android.os.Parcelable
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen
|
import org.dueattendant149.bookshelf.presentation.settings.BookshelfSettingsScreen
|
||||||
|
|
@ -30,12 +29,9 @@ object SettingsScreen : Screen, Parcelable {
|
||||||
val settings = LocalSettings.current
|
val settings = LocalSettings.current
|
||||||
val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior()
|
val (scrollBehavior, listState) = TopAppBarDefaults.collapsibleTopAppBarScrollBehavior()
|
||||||
|
|
||||||
val isRoot = navigator.items.collectAsStateWithLifecycle().value.size <= 1
|
|
||||||
|
|
||||||
SettingsContent(
|
SettingsContent(
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
isRoot = isRoot,
|
|
||||||
navigateToGeneralSettings = {
|
navigateToGeneralSettings = {
|
||||||
navigator.push(GeneralSettingsScreen)
|
navigator.push(GeneralSettingsScreen)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.tts
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.Observer
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.work.Constraints
|
|
||||||
import androidx.work.ExistingWorkPolicy
|
|
||||||
import androidx.work.NetworkType
|
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
|
||||||
import androidx.work.WorkInfo
|
|
||||||
import androidx.work.WorkManager
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.dueattendant149.bookshelf.data.worker.TtsDownloadWorker
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsEngine
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsJob
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.tts.TtsVoice
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.tts.CreateTtsJobUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsEnginesUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.tts.FetchTtsVoicesUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.tts.GetTtsJobUseCase
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
data class TtsState(
|
|
||||||
val engines: List<TtsEngine> = emptyList(),
|
|
||||||
val voices: List<TtsVoice> = emptyList(),
|
|
||||||
val selectedEngine: TtsEngine? = null,
|
|
||||||
val selectedVoice: TtsVoice? = null,
|
|
||||||
val speed: Double = 1.0,
|
|
||||||
val job: TtsJob? = null,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val isCreatingJob: Boolean = false,
|
|
||||||
val error: String? = null,
|
|
||||||
val outputFilePath: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class TtsModel
|
|
||||||
@Inject
|
|
||||||
constructor(
|
|
||||||
private val application: Application,
|
|
||||||
private val fetchTtsEnginesUseCase: FetchTtsEnginesUseCase,
|
|
||||||
private val fetchTtsVoicesUseCase: FetchTtsVoicesUseCase,
|
|
||||||
private val createTtsJobUseCase: CreateTtsJobUseCase,
|
|
||||||
private val getTtsJobUseCase: GetTtsJobUseCase,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(TtsState())
|
|
||||||
val state = _state.asStateFlow()
|
|
||||||
|
|
||||||
private val workManager = WorkManager.getInstance(application)
|
|
||||||
|
|
||||||
private var observedWork: LiveData<WorkInfo?>? = null
|
|
||||||
private var workObserver: Observer<WorkInfo?>? = null
|
|
||||||
|
|
||||||
fun load(bookRemoteId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
|
||||||
|
|
||||||
val enginesResult = fetchTtsEnginesUseCase()
|
|
||||||
val engines = enginesResult.getOrDefault(emptyList())
|
|
||||||
|
|
||||||
val selectedEngine = engines.firstOrNull()
|
|
||||||
val voicesResult = selectedEngine?.let { fetchTtsVoicesUseCase(it.id) }
|
|
||||||
?: fetchTtsVoicesUseCase()
|
|
||||||
val voices = voicesResult?.getOrDefault(emptyList()) ?: emptyList()
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
engines = engines,
|
|
||||||
voices = voices,
|
|
||||||
selectedEngine = selectedEngine,
|
|
||||||
selectedVoice = voices.firstOrNull(),
|
|
||||||
error = enginesResult.exceptionOrNull()?.message
|
|
||||||
?: voicesResult?.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectEngine(engine: TtsEngine) {
|
|
||||||
_state.update { it.copy(selectedEngine = engine, selectedVoice = null) }
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isLoading = true, error = null) }
|
|
||||||
val result = fetchTtsVoicesUseCase(engine.id)
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
isLoading = false,
|
|
||||||
voices = result.getOrDefault(emptyList()),
|
|
||||||
selectedVoice = result.getOrDefault(emptyList()).firstOrNull(),
|
|
||||||
error = result.exceptionOrNull()?.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun selectVoice(voice: TtsVoice) {
|
|
||||||
_state.update { it.copy(selectedVoice = voice) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateSpeed(speed: Double) {
|
|
||||||
_state.update { it.copy(speed = speed.coerceIn(0.5, 2.0)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createJob(bookRemoteId: String) {
|
|
||||||
val engine = _state.value.selectedEngine ?: return
|
|
||||||
val voice = _state.value.selectedVoice ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
_state.update { it.copy(isCreatingJob = true, error = null, job = null, outputFilePath = null) }
|
|
||||||
val result = createTtsJobUseCase(
|
|
||||||
bookId = bookRemoteId,
|
|
||||||
engine = engine.id,
|
|
||||||
voiceId = voice.id,
|
|
||||||
speed = _state.value.speed,
|
|
||||||
)
|
|
||||||
val job = result.getOrElse {
|
|
||||||
_state.update { it.copy(isCreatingJob = false, error = it.error ?: application.getString(R.string.tts_create_job_failed)) }
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
_state.update { it.copy(isCreatingJob = false, job = job) }
|
|
||||||
enqueueDownloadWorker(job, bookRemoteId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun refreshJob(jobId: String) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
val result = getTtsJobUseCase(jobId)
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
job = result.getOrNull() ?: it.job,
|
|
||||||
error = result.exceptionOrNull()?.message ?: it.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun enqueueDownloadWorker(job: TtsJob, bookRemoteId: String) {
|
|
||||||
val request = OneTimeWorkRequestBuilder<TtsDownloadWorker>()
|
|
||||||
.setConstraints(
|
|
||||||
Constraints.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.setInputData(
|
|
||||||
TtsDownloadWorker.createInputData(
|
|
||||||
jobId = job.jobId,
|
|
||||||
bookId = bookRemoteId,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
workManager.enqueueUniqueWork(
|
|
||||||
"tts_download_${job.jobId}",
|
|
||||||
ExistingWorkPolicy.KEEP,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
|
|
||||||
observeWorker(request.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeWorker(workId: java.util.UUID) {
|
|
||||||
workObserver?.let { observer ->
|
|
||||||
observedWork?.removeObserver(observer)
|
|
||||||
}
|
|
||||||
|
|
||||||
val liveData = workManager.getWorkInfoByIdLiveData(workId)
|
|
||||||
val observer: Observer<WorkInfo?> = Observer { info ->
|
|
||||||
info ?: return@Observer
|
|
||||||
when (info.state) {
|
|
||||||
WorkInfo.State.SUCCEEDED -> {
|
|
||||||
val filePath = info.outputData.getString(TtsDownloadWorker.KEY_OUTPUT_FILE_PATH)
|
|
||||||
_state.update { it.copy(outputFilePath = filePath) }
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkInfo.State.FAILED -> {
|
|
||||||
val message = info.outputData.getString(TtsDownloadWorker.KEY_ERROR_MESSAGE)
|
|
||||||
_state.update { it.copy(error = message ?: application.getString(R.string.tts_download_failed)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkInfo.State.RUNNING -> {
|
|
||||||
val progress = info.progress.getDouble(TtsDownloadWorker.PROGRESS_PROGRESS, 0.0)
|
|
||||||
val status = info.progress.getString(TtsDownloadWorker.PROGRESS_STATUS) ?: ""
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
job = it.job?.copy(status = status, progress = progress)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
observedWork = liveData
|
|
||||||
workObserver = observer
|
|
||||||
liveData.observeForever(observer)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
workObserver?.let { observer ->
|
|
||||||
observedWork?.removeObserver(observer)
|
|
||||||
}
|
|
||||||
super.onCleared()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.tts
|
|
||||||
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import kotlinx.parcelize.Parcelize
|
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
import org.dueattendant149.bookshelf.ui.tts.TtsContent
|
|
||||||
|
|
||||||
@Parcelize
|
|
||||||
data class TtsScreen(val bookRemoteId: String) : Screen, Parcelable {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
override fun Content() {
|
|
||||||
val model = hiltViewModel<TtsModel>()
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
TtsContent(
|
|
||||||
model = model,
|
|
||||||
bookRemoteId = bookRemoteId,
|
|
||||||
navigateBack = navigator::pop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,25 +8,35 @@ package org.dueattendant149.bookshelf.ui.bookshelf
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material.ExperimentalMaterialApi
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.CloudQueue
|
import androidx.compose.material.icons.filled.CloudQueue
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -40,62 +50,35 @@ import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.dueattendant149.bookshelf.R
|
import org.dueattendant149.bookshelf.R
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel
|
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
|
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
|
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton
|
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
|
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
|
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RemoteLibraryContent(
|
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
model: RemoteLibraryViewModel,
|
|
||||||
titleRes: Int,
|
|
||||||
onPlayAudio: (Book) -> Unit,
|
|
||||||
navigateToTts: (bookRemoteId: String) -> Unit,
|
|
||||||
) {
|
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
val refreshState = rememberPullRefreshState(
|
val refreshState = rememberPullRefreshState(
|
||||||
refreshing = state.isLoading,
|
refreshing = state.isLoading,
|
||||||
onRefresh = model::loadLibraries
|
onRefresh = model::loadLibraries
|
||||||
)
|
)
|
||||||
|
|
||||||
val sortedBooks = remember(state.books) {
|
val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
|
||||||
state.books.sortedByDescending { it.lastOpened ?: 0L }
|
if (!state.offlineOnly) state.books
|
||||||
}
|
else state.books.filter { book ->
|
||||||
|
val status = state.cacheStatuses[book.remoteId]
|
||||||
val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) {
|
status?.state == CacheState.COMPLETED ||
|
||||||
if (!state.offlineOnly) sortedBooks
|
status?.state == CacheState.DOWNLOADING ||
|
||||||
else sortedBooks.filter { book ->
|
status?.state == CacheState.PENDING
|
||||||
when (state.cacheStatuses[book.remoteId]?.state) {
|
|
||||||
CacheState.COMPLETED,
|
|
||||||
CacheState.DOWNLOADING,
|
|
||||||
CacheState.PENDING -> true
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val onlineIndicator = remember(state.serverOnline) {
|
|
||||||
if (state.serverOnline) ""
|
|
||||||
else " • Offline"
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
contentWindowInsets = WindowInsets(0),
|
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = { Text(stringResource(R.string.bookshelf_screen)) }
|
||||||
Text(
|
|
||||||
text = stringResource(titleRes) + onlineIndicator,
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
@ -110,71 +93,95 @@ fun RemoteLibraryContent(
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
items(
|
item {
|
||||||
items = visibleBooks,
|
Text(
|
||||||
key = { it.remoteId }
|
text = stringResource(R.string.bookshelf_libraries_header),
|
||||||
) { book ->
|
style = MaterialTheme.typography.titleSmall,
|
||||||
val cacheStatus = state.cacheStatuses[book.remoteId]
|
color = MaterialTheme.colorScheme.primary,
|
||||||
val isCaching = state.cachingBookId == book.remoteId
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
|
|
||||||
val progress = when {
|
|
||||||
book.hasAudio -> {
|
|
||||||
val dur = book.audioDuration.coerceAtLeast(1L)
|
|
||||||
(book.audioCurrentPosition.toFloat() / dur).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
else -> book.progress.coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val primaryLabel: String
|
|
||||||
val secondaryLabel: String
|
|
||||||
val onPrimaryClick: () -> Unit
|
|
||||||
val onSecondaryClick: () -> Unit
|
|
||||||
|
|
||||||
when {
|
|
||||||
book.hasAudio -> {
|
|
||||||
primaryLabel = stringResource(R.string.bookshelf_play_audio_action)
|
|
||||||
secondaryLabel = stringResource(R.string.chapters_button)
|
|
||||||
onPrimaryClick = { onPlayAudio(book) }
|
|
||||||
onSecondaryClick = { navigator.push(ChapterListScreen(book)) }
|
|
||||||
}
|
|
||||||
book.hasEbook -> {
|
|
||||||
primaryLabel = stringResource(R.string.read)
|
|
||||||
secondaryLabel = stringResource(R.string.tts_button)
|
|
||||||
onPrimaryClick = { model.openBook(book) }
|
|
||||||
onSecondaryClick = { navigateToTts(book.remoteId) }
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
primaryLabel = ""
|
|
||||||
secondaryLabel = ""
|
|
||||||
onPrimaryClick = {}
|
|
||||||
onSecondaryClick = {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BookCard(
|
|
||||||
book = book,
|
|
||||||
progress = progress,
|
|
||||||
primaryButton = {
|
|
||||||
if (primaryLabel.isNotBlank()) {
|
|
||||||
PrimaryActionButton(
|
|
||||||
label = primaryLabel,
|
|
||||||
enabled = !isCaching,
|
|
||||||
onClick = onPrimaryClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
secondaryButton = {
|
|
||||||
if (secondaryLabel.isNotBlank()) {
|
|
||||||
SecondaryActionButton(
|
|
||||||
label = secondaryLabel,
|
|
||||||
onClick = onSecondaryClick
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isLoading && state.books.isEmpty()) {
|
items(state.libraries.size) { index ->
|
||||||
|
val library = state.libraries[index]
|
||||||
|
val selected = state.selectedLibrary?.id == library.id
|
||||||
|
Card(
|
||||||
|
onClick = { model.selectLibrary(library) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = library.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.bookshelf_library_subtitle,
|
||||||
|
library.mediaType,
|
||||||
|
library.itemCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (selected) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bookshelf_selected_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.selectedLibrary?.let { library ->
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bookshelf_books_header, library.name),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.bookshelf_offline_only_label),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = state.offlineOnly,
|
||||||
|
onCheckedChange = { model.toggleOfflineOnly() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(visibleBooks.size) { index ->
|
||||||
|
val book = visibleBooks[index]
|
||||||
|
val cacheStatus = state.cacheStatuses[book.remoteId]
|
||||||
|
val isCaching = state.cachingBookId == book.remoteId
|
||||||
|
BookListItem(
|
||||||
|
book = book,
|
||||||
|
cacheStatus = cacheStatus,
|
||||||
|
isCaching = isCaching,
|
||||||
|
onDownload = { model.cacheBook(book) },
|
||||||
|
onDelete = { model.deleteCache(book) },
|
||||||
|
onRead = if (book.hasEbook) ({ model.openBook(book) }) else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -188,7 +195,7 @@ fun RemoteLibraryContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isLoading && state.books.isEmpty() && state.error == null) {
|
if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
|
||||||
EmptyPlaceholder(
|
EmptyPlaceholder(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
message = stringResource(R.string.bookshelf_empty_message),
|
message = stringResource(R.string.bookshelf_empty_message),
|
||||||
|
|
@ -198,7 +205,6 @@ fun RemoteLibraryContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
state.error?.let { error ->
|
state.error?.let { error ->
|
||||||
ErrorPlaceholder(
|
ErrorPlaceholder(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
|
@ -220,4 +226,102 @@ fun RemoteLibraryContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookListItem(
|
||||||
|
book: Book,
|
||||||
|
cacheStatus: CacheStatus?,
|
||||||
|
isCaching: Boolean,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
onRead: (() -> Unit)?,
|
||||||
|
) {
|
||||||
|
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
||||||
|
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
||||||
|
val showDelete = cacheState != CacheState.NONE
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(
|
||||||
|
text = book.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = book.author.getAsString()
|
||||||
|
?: stringResource(R.string.unknown_author)
|
||||||
|
)
|
||||||
|
if (cacheState != CacheState.NONE) {
|
||||||
|
Text(
|
||||||
|
text = cacheStatusLabel(cacheState),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
Row {
|
||||||
|
if (showDownload) {
|
||||||
|
IconButton(onClick = onDownload) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Download,
|
||||||
|
contentDescription = stringResource(R.string.bookshelf_download_action)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showDelete) {
|
||||||
|
IconButton(onClick = onDelete) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = stringResource(R.string.bookshelf_delete_cache_action)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cacheState == CacheState.DOWNLOADING) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { cacheStatus?.progress?.coerceIn(0f, 1f) ?: 0f },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onRead?.let { read ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.End
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = read,
|
||||||
|
enabled = !isCaching
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.read))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun cacheStatusLabel(state: CacheState): String =
|
||||||
|
when (state) {
|
||||||
|
CacheState.NONE -> ""
|
||||||
|
CacheState.PENDING -> stringResource(R.string.bookshelf_cache_pending)
|
||||||
|
CacheState.DOWNLOADING -> stringResource(R.string.bookshelf_cache_downloading)
|
||||||
|
CacheState.COMPLETED -> stringResource(R.string.bookshelf_cache_completed)
|
||||||
|
CacheState.FAILED -> stringResource(R.string.bookshelf_cache_failed)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,184 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.ui.common.components.book
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import coil.compose.AsyncImage
|
|
||||||
import coil.request.ImageRequest
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BookCard(
|
|
||||||
book: Book,
|
|
||||||
progress: Float,
|
|
||||||
primaryButton: @Composable (() -> Unit)? = null,
|
|
||||||
secondaryButton: @Composable (() -> Unit)? = null,
|
|
||||||
coverBadge: (@Composable () -> Unit)? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val shape = RoundedCornerShape(16.dp)
|
|
||||||
val coercedProgress = progress.coerceIn(0f, 1f)
|
|
||||||
Card(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.shadow(elevation = 4.dp, shape = shape, clip = false),
|
|
||||||
shape = shape,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(IntrinsicSize.Min)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// Fixed-height vertical cover on the left
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.height(120.dp)
|
|
||||||
.aspectRatio(2f / 3f)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
if (book.coverUrl.isNotBlank()) {
|
|
||||||
AsyncImage(
|
|
||||||
model = ImageRequest.Builder(LocalContext.current)
|
|
||||||
.data(Uri.parse(book.coverUrl))
|
|
||||||
.crossfade(200)
|
|
||||||
.build(),
|
|
||||||
contentDescription = stringResource(R.string.cover_image_content_desc),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CoverPlaceholder(book = book)
|
|
||||||
}
|
|
||||||
coverBadge?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
|
|
||||||
// Title / author / progress in the middle
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = book.title,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = book.author.getAsString() ?: stringResource(R.string.unknown_author),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { coercedProgress },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(4.dp)
|
|
||||||
.clip(RoundedCornerShape(2.dp)),
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
|
|
||||||
// Two pill buttons stacked vertically on the right
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
secondaryButton?.invoke()
|
|
||||||
primaryButton?.invoke()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CoverPlaceholder(book: Book) {
|
|
||||||
val seed = remember(book.title, book.author.getAsString()) {
|
|
||||||
book.title + book.author.getAsString()
|
|
||||||
}
|
|
||||||
val color = rememberCoverColor(seed = seed)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(color),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = book.title.take(1).uppercase(),
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = Color.White,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun rememberCoverColor(seed: String): Color {
|
|
||||||
val palette = remember {
|
|
||||||
listOf(
|
|
||||||
Color(0xFF1E88E5),
|
|
||||||
Color(0xFF43A047),
|
|
||||||
Color(0xFFE53935),
|
|
||||||
Color(0xFF8E24AA),
|
|
||||||
Color(0xFFFDD835),
|
|
||||||
Color(0xFF00897B),
|
|
||||||
Color(0xFF3949AB),
|
|
||||||
Color(0xFFD81B60),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val index = (seed.hashCode() % palette.size).let { if (it < 0) it + palette.size else it }
|
|
||||||
return palette[index]
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
package org.dueattendant149.bookshelf.ui.common.components.book
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PrimaryActionButton(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = enabled,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(min = 84.dp)
|
|
||||||
.height(40.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SecondaryActionButton(
|
|
||||||
label: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = onClick,
|
|
||||||
enabled = enabled,
|
|
||||||
shape = RoundedCornerShape(50),
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(min = 84.dp)
|
|
||||||
.height(40.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,272 +0,0 @@
|
||||||
/*
|
|
||||||
* Book's Story — free and open-source Material You eBook reader.
|
|
||||||
* Copyright (C) 2024-2026 Acclorite
|
|
||||||
* SPDX-License-Identifier: GPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.ui.player
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.CloudDownload
|
|
||||||
import androidx.compose.material.icons.filled.Pause
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.SkipNext
|
|
||||||
import androidx.compose.material.icons.filled.SkipPrevious
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import org.dueattendant149.bookshelf.R
|
|
||||||
import org.dueattendant149.bookshelf.presentation.player.PlayerState
|
|
||||||
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun PlayerContent(
|
|
||||||
state: StateFlow<PlayerState>,
|
|
||||||
onPlayPause: () -> Unit,
|
|
||||||
onSeek: (Long) -> Unit,
|
|
||||||
onSeekForward: () -> Unit,
|
|
||||||
onSeekBackward: () -> Unit,
|
|
||||||
onSkipNext: () -> Unit,
|
|
||||||
onSkipPrevious: () -> Unit,
|
|
||||||
onSetSpeed: (Float) -> Unit,
|
|
||||||
onSelectTrack: (Int) -> Unit,
|
|
||||||
onOpenChapters: () -> Unit,
|
|
||||||
onDownloadAudiobook: () -> Unit,
|
|
||||||
) {
|
|
||||||
val playerState by state.collectAsStateWithLifecycle()
|
|
||||||
val navigator = LocalNavigator.current
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(playerState.book.title) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { navigator.pop() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.go_back_content_desc)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = playerState.book.author.getAsString() ?: "",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
|
|
||||||
val currentTrack = playerState.tracks.getOrNull(playerState.currentTrackIndex)
|
|
||||||
Text(
|
|
||||||
text = currentTrack?.title ?: stringResource(R.string.player_no_track),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (playerState.isLoading) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
|
|
||||||
playerState.error?.let { error ->
|
|
||||||
Text(
|
|
||||||
text = error,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
val duration = playerState.durationMs.coerceAtLeast(1L)
|
|
||||||
val positionFraction = remember(playerState.currentPositionMs, duration) {
|
|
||||||
(playerState.currentPositionMs / duration.toFloat()).coerceIn(0f, 1f)
|
|
||||||
}
|
|
||||||
var sliderPosition by remember(positionFraction) { mutableFloatStateOf(positionFraction) }
|
|
||||||
Slider(
|
|
||||||
value = sliderPosition,
|
|
||||||
onValueChange = { sliderPosition = it },
|
|
||||||
onValueChangeFinished = {
|
|
||||||
onSeek((sliderPosition * duration).roundToInt().toLong())
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(formatMs(playerState.currentPositionMs))
|
|
||||||
Text(formatMs(playerState.durationMs))
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onSeekBackward) {
|
|
||||||
Text(stringResource(R.string.player_seek_backward))
|
|
||||||
}
|
|
||||||
IconButton(onClick = onSkipPrevious) {
|
|
||||||
Icon(Icons.Default.SkipPrevious, contentDescription = stringResource(R.string.player_previous))
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = onPlayPause,
|
|
||||||
modifier = Modifier.size(64.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (playerState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
|
||||||
contentDescription = stringResource(
|
|
||||||
if (playerState.isPlaying) R.string.player_pause else R.string.player_play
|
|
||||||
),
|
|
||||||
modifier = Modifier.size(48.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = onSkipNext) {
|
|
||||||
Icon(Icons.Default.SkipNext, contentDescription = stringResource(R.string.player_next))
|
|
||||||
}
|
|
||||||
IconButton(onClick = onSeekForward) {
|
|
||||||
Text(stringResource(R.string.player_seek_forward))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
|
||||||
) {
|
|
||||||
listOf(0.5f, 1f, 1.25f, 1.5f, 2f).forEach { speed ->
|
|
||||||
TextButton(
|
|
||||||
onClick = { onSetSpeed(speed) },
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${speed}x",
|
|
||||||
color = if (playerState.playbackSpeed == speed) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onOpenChapters,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Главы")
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(
|
|
||||||
onClick = onDownloadAudiobook,
|
|
||||||
enabled = !playerState.isCaching,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
if (playerState.isCaching) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { playerState.cacheProgress.coerceIn(0f, 1f) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(0.6f)
|
|
||||||
.height(4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CloudDownload,
|
|
||||||
contentDescription = "Скачать аудиокнигу"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
itemsIndexed(
|
|
||||||
items = playerState.tracks,
|
|
||||||
key = { _, track -> track.fileId }
|
|
||||||
) { index, track ->
|
|
||||||
val selected = index == playerState.currentTrackIndex
|
|
||||||
Card(
|
|
||||||
onClick = { onSelectTrack(index) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "${index + 1}. ${track.title}",
|
|
||||||
color = if (selected) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
modifier = Modifier.padding(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun formatMs(ms: Long): String {
|
|
||||||
val totalSeconds = ms / 1000
|
|
||||||
val hours = totalSeconds / 3600
|
|
||||||
val minutes = (totalSeconds % 3600) / 60
|
|
||||||
val seconds = totalSeconds % 60
|
|
||||||
return if (hours > 0) {
|
|
||||||
String.format("%d:%02d:%02d", hours, minutes, seconds)
|
|
||||||
} else {
|
|
||||||
String.format("%02d:%02d", minutes, seconds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -50,7 +50,6 @@ import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
|
||||||
import org.dueattendant149.bookshelf.ui.common.model.Direction
|
import org.dueattendant149.bookshelf.ui.common.model.Direction
|
||||||
import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition
|
import org.dueattendant149.bookshelf.ui.theme.HorizontalExpandingTransition
|
||||||
import org.dueattendant149.bookshelf.ui.theme.readerBarsColor
|
import org.dueattendant149.bookshelf.ui.theme.readerBarsColor
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ReaderBottomBar(
|
fun ReaderBottomBar(
|
||||||
|
|
@ -61,10 +60,6 @@ fun ReaderBottomBar(
|
||||||
lockMenu: Boolean,
|
lockMenu: Boolean,
|
||||||
checkpoints: List<Checkpoint>,
|
checkpoints: List<Checkpoint>,
|
||||||
bottomBarPadding: Dp,
|
bottomBarPadding: Dp,
|
||||||
readerPagination: Boolean,
|
|
||||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
|
||||||
currentPage: Int,
|
|
||||||
onChangePage: (Int) -> Unit,
|
|
||||||
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
|
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
|
||||||
scroll: (ReaderEvent.OnScroll) -> Unit,
|
scroll: (ReaderEvent.OnScroll) -> Unit,
|
||||||
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
|
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
|
||||||
|
|
@ -143,17 +138,13 @@ fun ReaderBottomBar(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
slider = {
|
slider = {
|
||||||
ReaderBottomBarSlider(
|
ReaderBottomBarSlider(
|
||||||
book = book,
|
book = book,
|
||||||
lockMenu = lockMenu,
|
lockMenu = lockMenu,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
readerPagination = readerPagination,
|
scroll = scroll,
|
||||||
pages = pages,
|
changeProgress = changeProgress
|
||||||
currentPage = currentPage,
|
)
|
||||||
onChangePage = onChangePage,
|
|
||||||
scroll = scroll,
|
|
||||||
changeProgress = changeProgress
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
indicator = {
|
indicator = {
|
||||||
ReaderBottomBarCheckpointsIndicator(
|
ReaderBottomBarCheckpointsIndicator(
|
||||||
|
|
@ -249,10 +240,6 @@ private fun ReaderBottomBarSlider(
|
||||||
book: Book,
|
book: Book,
|
||||||
lockMenu: Boolean,
|
lockMenu: Boolean,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
readerPagination: Boolean,
|
|
||||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
|
||||||
currentPage: Int,
|
|
||||||
onChangePage: (Int) -> Unit,
|
|
||||||
scroll: (ReaderEvent.OnScroll) -> Unit,
|
scroll: (ReaderEvent.OnScroll) -> Unit,
|
||||||
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
|
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -268,17 +255,7 @@ private fun ReaderBottomBarSlider(
|
||||||
value = progress,
|
value = progress,
|
||||||
enabled = !lockMenu,
|
enabled = !lockMenu,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
if (readerPagination && pages.isNotEmpty()) {
|
if (listState.layoutInfo.totalItemsCount > 0) {
|
||||||
val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex)
|
|
||||||
onChangePage(page)
|
|
||||||
changeProgress(
|
|
||||||
ReaderEvent.OnChangeProgress(
|
|
||||||
progress = it,
|
|
||||||
firstVisibleItemIndex = pages[page].startTextIndex,
|
|
||||||
firstVisibleItemOffset = 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else if (listState.layoutInfo.totalItemsCount > 0) {
|
|
||||||
scroll(ReaderEvent.OnScroll(it))
|
scroll(ReaderEvent.OnScroll(it))
|
||||||
changeProgress(
|
changeProgress(
|
||||||
ReaderEvent.OnChangeProgress(
|
ReaderEvent.OnChangeProgress(
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,6 @@ fun ReaderContent(
|
||||||
checkpoints: List<Checkpoint>,
|
checkpoints: List<Checkpoint>,
|
||||||
showMenu: Boolean,
|
showMenu: Boolean,
|
||||||
lockMenu: Boolean,
|
lockMenu: Boolean,
|
||||||
readerPagination: Boolean,
|
|
||||||
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
|
||||||
currentPage: Int,
|
|
||||||
onPageChanged: (Int) -> Unit,
|
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
verticalPadding: Dp,
|
verticalPadding: Dp,
|
||||||
horizontalGesture: ReaderHorizontalGesture,
|
horizontalGesture: ReaderHorizontalGesture,
|
||||||
|
|
@ -115,7 +111,6 @@ fun ReaderContent(
|
||||||
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
||||||
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit,
|
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit,
|
||||||
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
||||||
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
|
|
||||||
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
||||||
) {
|
) {
|
||||||
ReaderBottomSheet(
|
ReaderBottomSheet(
|
||||||
|
|
@ -125,33 +120,29 @@ fun ReaderContent(
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isLoading || errorMessage == null) {
|
if (isLoading || errorMessage == null) {
|
||||||
ReaderScaffold(
|
ReaderScaffold(
|
||||||
book = book,
|
book = book,
|
||||||
text = text,
|
text = text,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
currentChapter = currentChapter,
|
currentChapter = currentChapter,
|
||||||
nestedScrollConnection = nestedScrollConnection,
|
nestedScrollConnection = nestedScrollConnection,
|
||||||
fastColorPresetChange = fastColorPresetChange,
|
fastColorPresetChange = fastColorPresetChange,
|
||||||
perceptionExpander = perceptionExpander,
|
perceptionExpander = perceptionExpander,
|
||||||
perceptionExpanderPadding = perceptionExpanderPadding,
|
perceptionExpanderPadding = perceptionExpanderPadding,
|
||||||
perceptionExpanderThickness = perceptionExpanderThickness,
|
perceptionExpanderThickness = perceptionExpanderThickness,
|
||||||
horizontalLimiter = horizontalLimiter,
|
horizontalLimiter = horizontalLimiter,
|
||||||
horizontalLimiterHeight = horizontalLimiterHeight,
|
horizontalLimiterHeight = horizontalLimiterHeight,
|
||||||
horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
|
horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
|
||||||
horizontalLimiterRuler = horizontalLimiterRuler,
|
horizontalLimiterRuler = horizontalLimiterRuler,
|
||||||
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
|
||||||
horizontalLimiterDimming = horizontalLimiterDimming,
|
horizontalLimiterDimming = horizontalLimiterDimming,
|
||||||
currentChapterProgress = currentChapterProgress,
|
currentChapterProgress = currentChapterProgress,
|
||||||
isLoading = isLoading,
|
isLoading = isLoading,
|
||||||
checkpoints = checkpoints,
|
checkpoints = checkpoints,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
lockMenu = lockMenu,
|
lockMenu = lockMenu,
|
||||||
readerPagination = readerPagination,
|
contentPadding = contentPadding,
|
||||||
pages = pages,
|
verticalPadding = verticalPadding,
|
||||||
currentPage = currentPage,
|
|
||||||
onPageChanged = onPageChanged,
|
|
||||||
contentPadding = contentPadding,
|
|
||||||
verticalPadding = verticalPadding,
|
|
||||||
horizontalGesture = horizontalGesture,
|
horizontalGesture = horizontalGesture,
|
||||||
horizontalGestureScroll = horizontalGestureScroll,
|
horizontalGestureScroll = horizontalGestureScroll,
|
||||||
horizontalGestureSensitivity = horizontalGestureSensitivity,
|
horizontalGestureSensitivity = horizontalGestureSensitivity,
|
||||||
|
|
@ -200,8 +191,7 @@ fun ReaderContent(
|
||||||
showSettingsBottomSheet = showSettingsBottomSheet,
|
showSettingsBottomSheet = showSettingsBottomSheet,
|
||||||
showChaptersDrawer = showChaptersDrawer,
|
showChaptersDrawer = showChaptersDrawer,
|
||||||
navigateBack = navigateBack,
|
navigateBack = navigateBack,
|
||||||
navigateToBookInfo = navigateToBookInfo,
|
navigateToBookInfo = navigateToBookInfo
|
||||||
navigateToRsvp = navigateToRsvp
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ReaderErrorPlaceholder(
|
ReaderErrorPlaceholder(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import org.dueattendant149.bookshelf.R
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen
|
import org.dueattendant149.bookshelf.presentation.book_info.BookInfoScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.ReaderEffect
|
import org.dueattendant149.bookshelf.presentation.reader.ReaderEffect
|
||||||
import org.dueattendant149.bookshelf.presentation.rsvp.RsvpScreen
|
|
||||||
import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
|
||||||
import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity
|
import org.dueattendant149.bookshelf.ui.common.helpers.launchActivity
|
||||||
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
||||||
|
|
@ -184,10 +183,6 @@ fun ReaderEffects(
|
||||||
saveInBackStack = false
|
saveInBackStack = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is ReaderEffect.OnNavigateToRsvp -> {
|
|
||||||
navigator.push(RsvpScreen(bookId = effect.bookId))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue