feat: paginated reader sync, search crash fix, audio cache + unified auth
- Sync pagination measurement with rendered layout (page geometry, spacing, images, chapter extras) - Improve split algorithm with getLineForVerticalPosition and widow/orphan handling - Fix search crash from duplicate LazyColumn keys on empty guids - Switch BookCard cover to fixed-height vertical variant - Add ExoPlayer SimpleCache + CacheDataSource for streaming audio - Unify AuthorizationInterceptor for player via PlaybackAuthProvider - Bump version to 1.9.11 (27) and update AGENTS.md
This commit is contained in:
parent
71537406c6
commit
f7307f4199
98 changed files with 5866 additions and 893 deletions
29
AGENTS.md
29
AGENTS.md
|
|
@ -77,10 +77,35 @@
|
||||||
- Audiobook player with ExoPlayer + MediaSession.
|
- Audiobook player with ExoPlayer + MediaSession.
|
||||||
- TTS jobs screen via bookshelf-api.
|
- TTS jobs screen via bookshelf-api.
|
||||||
- Reading and playback progress sync with periodic WorkManager worker.
|
- 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 `BookStory-debug-6.apk` to ownCloud.
|
- 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`).
|
- 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.
|
- Raised `minSdk` from 26 to 31.
|
||||||
- Next: backend endpoint parity for progress sync, test on device, push to Forgejo once repo is created.
|
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ android {
|
||||||
applicationId = "org.dueattendant149.bookshelf"
|
applicationId = "org.dueattendant149.bookshelf"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 14
|
versionCode = 27
|
||||||
versionName = "1.8.0"
|
versionName = "1.9.11"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,507 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 19,
|
||||||
|
"identityHash": "7a70be4a2d1b3fd8f89dda543d18a6b4",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "BookEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `itemType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "author",
|
||||||
|
"columnName": "author",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filePath",
|
||||||
|
"columnName": "filePath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scrollIndex",
|
||||||
|
"columnName": "scrollIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scrollOffset",
|
||||||
|
"columnName": "scrollOffset",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "progress",
|
||||||
|
"columnName": "progress",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "image",
|
||||||
|
"columnName": "image",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'[]'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remoteId",
|
||||||
|
"columnName": "remoteId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "libraryId",
|
||||||
|
"columnName": "libraryId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "mediaType",
|
||||||
|
"columnName": "mediaType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "itemType",
|
||||||
|
"columnName": "itemType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "hasAudio",
|
||||||
|
"columnName": "hasAudio",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "hasEbook",
|
||||||
|
"columnName": "hasEbook",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioDuration",
|
||||||
|
"columnName": "audioDuration",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioCurrentFile",
|
||||||
|
"columnName": "audioCurrentFile",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioCurrentPosition",
|
||||||
|
"columnName": "audioCurrentPosition",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "coverUrl",
|
||||||
|
"columnName": "coverUrl",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "HistoryEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "time",
|
||||||
|
"columnName": "time",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ColorPresetEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "backgroundColor",
|
||||||
|
"columnName": "backgroundColor",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fontColor",
|
||||||
|
"columnName": "fontColor",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSelected",
|
||||||
|
"columnName": "isSelected",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "CategoryEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sortOrder",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'LAST_READ'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrderDescending",
|
||||||
|
"columnName": "sortOrderDescending",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "audio_file",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remotePath",
|
||||||
|
"columnName": "remotePath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_audio_file_bookId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "BookEntity",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ebook_file",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "format",
|
||||||
|
"columnName": "format",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remotePath",
|
||||||
|
"columnName": "remotePath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ebook_file_bookId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "BookEntity",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "cached_file",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remoteUrl",
|
||||||
|
"columnName": "remoteUrl",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "progress",
|
||||||
|
"columnName": "progress",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_cached_file_bookId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_cached_file_type",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a70be4a2d1b3fd8f89dda543d18a6b4')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
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 org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -24,10 +25,14 @@ class Application : Application(), Configuration.Provider {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var workManager: WorkManager
|
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)
|
ProgressSyncWorker.schedule(workManager)
|
||||||
|
serverStatusMonitor.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
override val workManagerConfiguration: Configuration
|
override val workManagerConfiguration: Configuration
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ object AppModule {
|
||||||
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_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)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import java.io.File
|
||||||
EbookFileEntity::class,
|
EbookFileEntity::class,
|
||||||
CachedFileEntity::class,
|
CachedFileEntity::class,
|
||||||
],
|
],
|
||||||
version = 18,
|
version = 19,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(1, 2),
|
AutoMigration(1, 2),
|
||||||
AutoMigration(2, 3),
|
AutoMigration(2, 3),
|
||||||
|
|
@ -203,6 +203,14 @@ 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) {
|
val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
database.execSQL(
|
database.execSQL(
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ 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,
|
||||||
|
|
@ -58,6 +59,7 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.playback.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CacheModule {
|
||||||
|
|
||||||
|
private const val CACHE_DIR_NAME = "audiobook_cache"
|
||||||
|
private const val CACHE_SIZE_BYTES = 512L * 1024 * 1024 // 512 MB
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSimpleCache(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): SimpleCache {
|
||||||
|
val cacheDir = File(context.externalCacheDir ?: context.cacheDir, CACHE_DIR_NAME)
|
||||||
|
if (!cacheDir.exists()) {
|
||||||
|
cacheDir.mkdirs()
|
||||||
|
}
|
||||||
|
return SimpleCache(
|
||||||
|
cacheDir,
|
||||||
|
LeastRecentlyUsedCacheEvictor(CACHE_SIZE_BYTES),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,26 @@
|
||||||
package org.dueattendant149.bookshelf.data.playback.di
|
package org.dueattendant149.bookshelf.data.playback.di
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
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
|
@Singleton
|
||||||
class PlaybackAuthProvider @Inject constructor() {
|
class PlaybackAuthProvider @Inject constructor() {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
var token: String = ""
|
var token: String = ""
|
||||||
|
private set
|
||||||
|
|
||||||
|
suspend fun refresh(serverSettings: ServerSettings) {
|
||||||
|
token = serverSettings.getAbsToken() ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setToken(value: String) {
|
||||||
|
token = value.trim()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -32,19 +33,7 @@ object PlaybackModule {
|
||||||
okHttpClient: OkHttpClient,
|
okHttpClient: OkHttpClient,
|
||||||
): ExoPlayer {
|
): ExoPlayer {
|
||||||
val client = okHttpClient.newBuilder()
|
val client = okHttpClient.newBuilder()
|
||||||
.addInterceptor { chain ->
|
.addInterceptor(AuthorizationInterceptor(authProvider::token))
|
||||||
val request = chain.request()
|
|
||||||
val token = authProvider.token
|
|
||||||
if (token.isNotBlank()) {
|
|
||||||
chain.proceed(
|
|
||||||
request.newBuilder()
|
|
||||||
.header("Authorization", "Bearer $token")
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
chain.proceed(request)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val dataSourceFactory = OkHttpDataSource.Factory(client)
|
val dataSourceFactory = OkHttpDataSource.Factory(client)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.data.remote
|
||||||
|
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an `Authorization: Bearer <token>` header when a non-blank token is
|
||||||
|
* available. The token is read lazily on every request so it can be updated
|
||||||
|
* without rebuilding the OkHttp client.
|
||||||
|
*/
|
||||||
|
class AuthorizationInterceptor(
|
||||||
|
private val tokenProvider: () -> String?,
|
||||||
|
) : Interceptor {
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
val request = chain.request()
|
||||||
|
val token = tokenProvider()?.trim()
|
||||||
|
return if (!token.isNullOrBlank()) {
|
||||||
|
chain.proceed(
|
||||||
|
request.newBuilder()
|
||||||
|
.header("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
chain.proceed(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
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.PlaybackProgressUpdateRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
||||||
|
|
@ -17,15 +19,20 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsEnginesRe
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsJobResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.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.POST
|
import retrofit2.http.Multipart
|
||||||
|
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).
|
||||||
|
|
@ -41,6 +48,24 @@ interface BookshelfApiService {
|
||||||
@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
|
@Streaming
|
||||||
@GET("api/v1/books/{itemId}")
|
@GET("api/v1/books/{itemId}")
|
||||||
suspend fun getBook(
|
suspend fun getBook(
|
||||||
|
|
@ -61,6 +86,11 @@ interface BookshelfApiService {
|
||||||
@Body request: PlaybackProgressUpdateRequest,
|
@Body request: PlaybackProgressUpdateRequest,
|
||||||
): Response<ResponseBody>
|
): 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(
|
||||||
@Path("libraryId") libraryId: String,
|
@Path("libraryId") libraryId: String,
|
||||||
|
|
@ -100,6 +130,13 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ServerStatusMonitor
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val clientFactory: BookshelfApiClientFactory,
|
||||||
|
private val serverSettings: ServerSettings,
|
||||||
|
) {
|
||||||
|
private val _isOnline = MutableStateFlow(false)
|
||||||
|
val isOnline = _isOnline.asStateFlow()
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var pingJob: Job? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (pingJob != null) return
|
||||||
|
pingJob = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val online = checkHealth()
|
||||||
|
_isOnline.value = online
|
||||||
|
delay(30_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
pingJob?.cancel()
|
||||||
|
pingJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkHealth(): Boolean {
|
||||||
|
val url = serverSettings.getBookshelfUrl() ?: return false
|
||||||
|
val client = clientFactory.provideClient(url) ?: return false
|
||||||
|
return runCatching {
|
||||||
|
val response = client.health()
|
||||||
|
response.isSuccessful
|
||||||
|
}.onFailure {
|
||||||
|
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
|
||||||
|
}.getOrDefault(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AudioTrackResponse(
|
||||||
|
@SerialName("file_id")
|
||||||
|
val fileId: String,
|
||||||
|
val title: String = "",
|
||||||
|
val duration: Double = 0.0,
|
||||||
|
val size: Long = 0L,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LibraryItemsResponse(
|
||||||
|
val library: LibraryResponse = LibraryResponse(""),
|
||||||
|
val items: List<UnifiedItemResponse> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnifiedItemResponse(
|
||||||
|
val id: String,
|
||||||
|
val title: String = "",
|
||||||
|
val authors: List<String> = emptyList(),
|
||||||
|
val author: String = "",
|
||||||
|
val type: String = "",
|
||||||
|
@SerialName("media_type")
|
||||||
|
val mediaType: String = "",
|
||||||
|
@SerialName("library_id")
|
||||||
|
val libraryId: String = "",
|
||||||
|
@SerialName("cover_url")
|
||||||
|
val coverUrl: String = "",
|
||||||
|
val duration: Double = 0.0,
|
||||||
|
val size: Long = 0L,
|
||||||
|
@SerialName("progress")
|
||||||
|
val progress: UnifiedItemProgressResponse? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UnifiedItemProgressResponse(
|
||||||
|
val reading: ReadingProgressResponse? = null,
|
||||||
|
val playback: PlaybackProgressResponse? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ReadingProgressResponse(
|
||||||
|
@SerialName("itemId")
|
||||||
|
val itemId: String = "",
|
||||||
|
@SerialName("libraryId")
|
||||||
|
val libraryId: String = "",
|
||||||
|
@SerialName("scrollIndex")
|
||||||
|
val scrollIndex: Int = 0,
|
||||||
|
@SerialName("scrollOffset")
|
||||||
|
val scrollOffset: Int = 0,
|
||||||
|
val progress: Float = 0f,
|
||||||
|
@SerialName("last_chapter")
|
||||||
|
val lastChapter: String? = null,
|
||||||
|
@SerialName("updatedAt")
|
||||||
|
val updatedAt: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlaybackProgressResponse(
|
||||||
|
@SerialName("itemId")
|
||||||
|
val itemId: String = "",
|
||||||
|
@SerialName("libraryId")
|
||||||
|
val libraryId: String = "",
|
||||||
|
@SerialName("current_file")
|
||||||
|
val currentFile: String = "",
|
||||||
|
@SerialName("current_position")
|
||||||
|
val currentPosition: Long = 0L,
|
||||||
|
val duration: Long = 0L,
|
||||||
|
@SerialName("updatedAt")
|
||||||
|
val updatedAt: Long = 0L,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UploadBookResponse(
|
||||||
|
val success: Boolean = false,
|
||||||
|
val filename: String = "",
|
||||||
|
val type: String = "",
|
||||||
|
val path: String = "",
|
||||||
|
@SerialName("error")
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
package org.dueattendant149.bookshelf.data.repository
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
||||||
|
|
@ -18,35 +18,30 @@ import javax.inject.Singleton
|
||||||
class AudiobookshelfRepositoryImpl
|
class AudiobookshelfRepositoryImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val clientFactory: AudiobookshelfApiClientFactory,
|
private val clientFactory: BookshelfApiClientFactory,
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
) : AudiobookshelfRepository {
|
) : AudiobookshelfRepository {
|
||||||
override suspend fun getAudioTracks(itemId: String): Result<List<AudioTrack>> {
|
override suspend fun getAudioTracks(itemId: String): Result<List<AudioTrack>> {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val url = serverSettings.getAbsUrl()
|
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
||||||
val token = serverSettings.getAbsToken()
|
if (bookshelfUrl.isNullOrBlank()) {
|
||||||
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
return Result.failure(IllegalStateException("Bookshelf API URL is not configured"))
|
||||||
return Result.failure(IllegalStateException("Audiobookshelf not configured"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = clientFactory.provideClient(url, token)
|
val client = clientFactory.provideClient(bookshelfUrl)
|
||||||
?: return Result.failure(IllegalStateException("Failed to create ABS client"))
|
?: return Result.failure(IllegalStateException("Failed to create Bookshelf API client"))
|
||||||
|
|
||||||
val response = client.getItem(itemId)
|
val response = client.getAudioTracks(itemId)
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
return Result.failure(RuntimeException("Failed to fetch item: ${response.code()}"))
|
return Result.failure(RuntimeException("Failed to fetch tracks: ${response.code()}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
val item = response.body()
|
response.body().orEmpty()
|
||||||
?: return Result.failure(RuntimeException("Empty item response"))
|
.mapIndexed { index, track ->
|
||||||
|
|
||||||
item.media?.audioFiles.orEmpty()
|
|
||||||
.mapIndexed { index, file ->
|
|
||||||
AudioTrack(
|
AudioTrack(
|
||||||
fileId = file.ino,
|
fileId = track.fileId,
|
||||||
title = file.metadata?.filename?.takeIf { it.isNotBlank() }
|
title = track.title.takeIf { it.isNotBlank() } ?: "Track ${index + 1}",
|
||||||
?: "Track ${index + 1}",
|
durationMs = (track.duration * 1000).toLong(),
|
||||||
durationMs = (file.duration * 1000).toLong(),
|
|
||||||
order = index,
|
order = index,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,26 @@
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
package org.dueattendant149.bookshelf.data.repository
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.webkit.URLUtil
|
import android.util.Log
|
||||||
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.audiobookshelf.AudiobookshelfApiClientFactory
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||||
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
|
||||||
|
|
@ -34,82 +38,68 @@ 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: AudiobookshelfApiClientFactory,
|
private val apiFactory: BookshelfApiClientFactory,
|
||||||
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.hasEbook) { "Book has no ebook." }
|
|
||||||
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
||||||
|
|
||||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||||
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
val localBookId = existing?.id ?: insertLocalBook(book)
|
||||||
|
|
||||||
if (existing != null &&
|
if (book.hasEbook) {
|
||||||
cachedEbook?.localPath != null &&
|
cacheEbook(book, localBookId)
|
||||||
File(cachedEbook.localPath).exists()
|
|
||||||
) {
|
|
||||||
if (existing.filePath != cachedEbook.localPath) {
|
|
||||||
database.bookDao.updateBook(
|
|
||||||
bookMapper.toBookEntity(
|
|
||||||
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return@withContext existing.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val url = serverSettings.getAbsUrl()
|
if (book.hasAudio) {
|
||||||
val token = serverSettings.getAbsToken()
|
cacheAudiobook(book, localBookId)
|
||||||
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
|
||||||
throw IllegalStateException("Audiobookshelf server is not configured.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val client = apiFactory.provideClient(url, token)
|
localBookId
|
||||||
?: throw IllegalStateException("Could not create Audiobookshelf client.")
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it)
|
||||||
|
}
|
||||||
|
|
||||||
val response = client.downloadBook(book.remoteId)
|
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) {
|
if (!response.isSuccessful) {
|
||||||
throw IllegalStateException("Download failed: ${response.code()}")
|
throw IllegalStateException("Ebook download failed: ${response.code()}")
|
||||||
}
|
}
|
||||||
|
val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.")
|
||||||
|
|
||||||
val body = response.body()
|
val file = saveFileToDir(
|
||||||
?: throw IllegalStateException("Download response body is empty.")
|
body,
|
||||||
|
dirName = "ebooks",
|
||||||
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
|
fileName = guessFileName(response, book.remoteId)
|
||||||
val fileName = guessFileName(response, book.remoteId)
|
|
||||||
val file = File(ebooksDir, fileName)
|
|
||||||
|
|
||||||
body.byteStream().use { input ->
|
|
||||||
file.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val localBook = if (existing != null) {
|
|
||||||
book.copy(
|
|
||||||
id = existing.id,
|
|
||||||
filePath = file.absolutePath,
|
|
||||||
scrollIndex = existing.scrollIndex,
|
|
||||||
scrollOffset = existing.scrollOffset,
|
|
||||||
progress = existing.progress,
|
|
||||||
categories = existing.categories,
|
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
book.copy(filePath = file.absolutePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
|
|
||||||
|
|
||||||
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
|
|
||||||
?: throw IllegalStateException("Could not insert or update book.")
|
|
||||||
|
|
||||||
database.ebookFileDao.insert(
|
database.ebookFileDao.insert(
|
||||||
EbookFileEntity(
|
EbookFileEntity(
|
||||||
bookId = inserted.id,
|
bookId = bookId,
|
||||||
fileId = book.remoteId,
|
fileId = book.remoteId,
|
||||||
format = file.extension,
|
format = file.extension,
|
||||||
localPath = file.absolutePath,
|
localPath = file.absolutePath,
|
||||||
|
|
@ -118,33 +108,158 @@ class CacheRepositoryImpl
|
||||||
|
|
||||||
cachedFileDao.insert(
|
cachedFileDao.insert(
|
||||||
CachedFileEntity(
|
CachedFileEntity(
|
||||||
bookId = inserted.id,
|
bookId = bookId,
|
||||||
type = "ebook",
|
type = "ebook",
|
||||||
remoteUrl = "$url/api/items/${book.remoteId}/download",
|
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook",
|
||||||
localPath = file.absolutePath,
|
localPath = file.absolutePath,
|
||||||
status = "completed",
|
status = "completed",
|
||||||
progress = 1f,
|
progress = 1f,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
inserted.id
|
updateBookFilePath(book, file.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun cacheAudiobook(book: Book, bookId: Int) {
|
||||||
|
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
||||||
|
val tracks = tracksResult.getOrThrow()
|
||||||
|
if (tracks.isEmpty()) {
|
||||||
|
Log.w("CacheRepository", "No audio tracks for ${book.remoteId}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val bookshelfUrl = serverSettings.getBookshelfUrl()
|
||||||
|
?: throw IllegalStateException("Bookshelf API URL is not configured.")
|
||||||
|
val client = apiFactory.provideClient(bookshelfUrl)
|
||||||
|
?: throw IllegalStateException("Could not create Bookshelf API client.")
|
||||||
|
|
||||||
|
// Persist track metadata if missing.
|
||||||
|
val existingTracks = audioFileDao.getByBookId(bookId)
|
||||||
|
if (existingTracks.isEmpty()) {
|
||||||
|
audioFileDao.insertAll(
|
||||||
|
tracks.mapIndexed { index, track ->
|
||||||
|
AudioFileEntity(
|
||||||
|
bookId = bookId,
|
||||||
|
fileId = track.fileId,
|
||||||
|
remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
|
||||||
|
duration = track.durationMs / 1000.0,
|
||||||
|
order = index,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks.forEachIndexed { index, track ->
|
||||||
|
val cached = audioFileDao.getByBookId(bookId)
|
||||||
|
.firstOrNull { it.fileId == track.fileId }
|
||||||
|
if (cached?.localPath != null && File(cached.localPath).exists()) {
|
||||||
|
updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath)
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading")
|
||||||
|
val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}"
|
||||||
|
val response = client.downloadAudioFile(book.remoteId, track.fileId)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
|
||||||
|
throw IllegalStateException("Audio track $index download failed: ${response.code()}")
|
||||||
|
}
|
||||||
|
val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
|
||||||
|
val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
|
||||||
|
|
||||||
|
audioFileDao.update(
|
||||||
|
(cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId))
|
||||||
|
.copy(localPath = file.absolutePath)
|
||||||
|
)
|
||||||
|
|
||||||
|
val overallProgress = (index + 1).toFloat() / tracks.size
|
||||||
|
updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed")
|
||||||
|
cachedFileDao.insert(
|
||||||
|
CachedFileEntity(
|
||||||
|
bookId = bookId,
|
||||||
|
type = "audio_${track.fileId}",
|
||||||
|
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
|
||||||
|
localPath = file.absolutePath,
|
||||||
|
status = "completed",
|
||||||
|
progress = overallProgress,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateAudioTrackProgress(
|
||||||
|
bookId: Int,
|
||||||
|
fileId: String,
|
||||||
|
progress: Float,
|
||||||
|
localPath: String?,
|
||||||
|
status: String = "completed",
|
||||||
|
) {
|
||||||
|
val type = "audio_$fileId"
|
||||||
|
val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull()
|
||||||
|
val entity = CachedFileEntity(
|
||||||
|
id = existing?.id ?: 0,
|
||||||
|
bookId = bookId,
|
||||||
|
type = type,
|
||||||
|
remoteUrl = existing?.remoteUrl ?: "",
|
||||||
|
localPath = localPath ?: existing?.localPath,
|
||||||
|
status = status,
|
||||||
|
progress = progress,
|
||||||
|
)
|
||||||
|
if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateBookFilePath(book: Book, path: String) {
|
||||||
|
val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
|
||||||
|
if (existing.filePath != path) {
|
||||||
|
database.bookDao.updateBook(
|
||||||
|
bookMapper.toBookEntity(
|
||||||
|
bookMapper.toBook(existing).copy(filePath = path)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File {
|
||||||
|
val dir = File(application.filesDir, dirName).apply { mkdirs() }
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
body.byteStream().use { input ->
|
||||||
|
file.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun guessAudioExtension(fileNameOrTitle: String): String {
|
||||||
|
val name = fileNameOrTitle.lowercase()
|
||||||
|
return when {
|
||||||
|
name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b"
|
||||||
|
name.endsWith(".mp3") -> "mp3"
|
||||||
|
name.endsWith(".flac") -> "flac"
|
||||||
|
name.endsWith(".ogg") -> "ogg"
|
||||||
|
name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b"
|
||||||
|
else -> "mp3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||||
?: throw IllegalStateException("Book not found")
|
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)
|
||||||
|
|
@ -170,20 +285,28 @@ class CacheRepositoryImpl
|
||||||
entities.any { it.status == "pending" } -> CacheState.PENDING
|
entities.any { it.status == "pending" } -> CacheState.PENDING
|
||||||
else -> CacheState.NONE
|
else -> CacheState.NONE
|
||||||
}
|
}
|
||||||
val progress = if (entities.isEmpty()) {
|
val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat()
|
||||||
0f
|
|
||||||
} else {
|
|
||||||
entities.map { it.progress }.average().toFloat()
|
|
||||||
}
|
|
||||||
emit(CacheStatus(remoteId, state, progress, files))
|
emit(CacheStatus(remoteId, state, progress, files))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
|
private fun guessFileName(response: Response<ResponseBody>, remoteId: String): String {
|
||||||
val contentDisposition = response.headers()["Content-Disposition"]
|
val contentDisposition = response.headers()["Content-Disposition"]
|
||||||
val mimeType = response.body()?.contentType()?.toString()
|
val mimeType = response.body()?.contentType()?.toString()
|
||||||
val url = response.raw().request.url.toString()
|
val url = response.raw().request.url.toString()
|
||||||
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
val name = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||||
return if (name.contains(".")) name else "$remoteId.epub"
|
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() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,21 @@
|
||||||
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
|
||||||
|
|
@ -23,6 +35,8 @@ 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 {
|
||||||
|
|
@ -38,7 +52,42 @@ class RemoteLibraryRepositoryImpl
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getBooks(libraryId: String): Result<List<Book>> {
|
override suspend fun getBooks(libraryId: String): Result<List<Book>> {
|
||||||
return searchLibrary(libraryId, "")
|
return runCatching {
|
||||||
|
val client = client() ?: return Result.success(emptyList())
|
||||||
|
val response = client.getLibraryItems(libraryId)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return Result.failure(RuntimeException("Failed to load library items: ${response.code()}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body() ?: LibraryItemsResponse()
|
||||||
|
val items = body.items.map { it.toBook() }
|
||||||
|
|
||||||
|
// Upsert each item into the local DB keyed by remoteId,
|
||||||
|
// preserving local file path/progress if the book already exists.
|
||||||
|
items.forEach { book ->
|
||||||
|
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||||
|
val toStore = if (existing != null) {
|
||||||
|
val existingBook = bookMapper.toBook(existing)
|
||||||
|
book.copy(
|
||||||
|
id = existingBook.id,
|
||||||
|
filePath = existingBook.filePath,
|
||||||
|
scrollIndex = existingBook.scrollIndex,
|
||||||
|
scrollOffset = existingBook.scrollOffset,
|
||||||
|
progress = existingBook.progress,
|
||||||
|
categories = existingBook.categories,
|
||||||
|
lastOpened = existingBook.lastOpened,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
book
|
||||||
|
}
|
||||||
|
database.bookDao.insertBook(bookMapper.toBookEntity(toStore))
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success(items)
|
||||||
|
}.getOrElse {
|
||||||
|
Log.e("RemoteLibraryRepo", "getBooks failed", it)
|
||||||
|
Result.failure(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun searchLibrary(
|
override suspend fun searchLibrary(
|
||||||
|
|
@ -57,6 +106,59 @@ class RemoteLibraryRepositoryImpl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun search(request: SearchRequest): Result<SearchResponse> {
|
||||||
|
return runCatching {
|
||||||
|
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
||||||
|
val response = client.search(request)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return Result.failure(RuntimeException("Search failed: ${response.code()}"))
|
||||||
|
}
|
||||||
|
response.body() ?: SearchResponse()
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("RemoteLibraryRepo", "search failed", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
|
||||||
|
return runCatching {
|
||||||
|
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
||||||
|
val response = client.startDownload(request)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return Result.failure(RuntimeException("Download failed: ${response.code()}"))
|
||||||
|
}
|
||||||
|
response.body() ?: DownloadResponse()
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("RemoteLibraryRepo", "startDownload failed", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listDownloads(): Result<DownloadsListResponse> {
|
||||||
|
return runCatching {
|
||||||
|
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
||||||
|
val response = client.listDownloads()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return Result.failure(RuntimeException("List downloads failed: ${response.code()}"))
|
||||||
|
}
|
||||||
|
response.body() ?: DownloadsListResponse()
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("RemoteLibraryRepo", "listDownloads failed", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun uploadBook(
|
||||||
|
file: MultipartBody.Part,
|
||||||
|
bookType: RequestBody,
|
||||||
|
): Result<UploadBookResponse> = runCatching {
|
||||||
|
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
|
||||||
|
val response = client.uploadBook(file, bookType)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
return Result.failure(RuntimeException("Upload failed: ${response.code()}"))
|
||||||
|
}
|
||||||
|
response.body() ?: UploadBookResponse(success = false, errorMessage = "Empty response")
|
||||||
|
}.onFailure {
|
||||||
|
Log.e("RemoteLibraryRepo", "uploadBook failed", it)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
|
||||||
|
|
||||||
private fun LibraryResponse.toDomain() =
|
private fun LibraryResponse.toDomain() =
|
||||||
|
|
@ -67,7 +169,54 @@ class RemoteLibraryRepositoryImpl
|
||||||
itemCount = itemCount,
|
itemCount = itemCount,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
|
private suspend fun UnifiedItemResponse.toBook(): Book {
|
||||||
|
val resolvedCoverUrl = resolveCoverUrl(coverUrl)
|
||||||
|
return Book(
|
||||||
|
id = 0,
|
||||||
|
title = title,
|
||||||
|
author = UIText.StringValue(
|
||||||
|
author.takeIf { it.isNotBlank() }
|
||||||
|
?: authors.filter { it.isNotBlank() }.joinToString(", ")
|
||||||
|
.takeIf { it.isNotBlank() }
|
||||||
|
?: ""
|
||||||
|
),
|
||||||
|
description = null,
|
||||||
|
filePath = "",
|
||||||
|
coverImage = null,
|
||||||
|
scrollIndex = progress?.reading?.scrollIndex ?: 0,
|
||||||
|
scrollOffset = progress?.reading?.scrollOffset ?: 0,
|
||||||
|
progress = progress?.reading?.progress ?: 0f,
|
||||||
|
lastOpened = null,
|
||||||
|
categories = emptyList(),
|
||||||
|
remoteId = id,
|
||||||
|
libraryId = libraryId,
|
||||||
|
mediaType = mediaType,
|
||||||
|
itemType = type,
|
||||||
|
hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid",
|
||||||
|
hasEbook = type == "ebook" || type == "hybrid",
|
||||||
|
audioDuration = progress?.playback?.duration ?: duration.toLong(),
|
||||||
|
audioCurrentFile = progress?.playback?.currentFile ?: "",
|
||||||
|
audioCurrentPosition = progress?.playback?.currentPosition ?: 0L,
|
||||||
|
coverUrl = resolvedCoverUrl,
|
||||||
|
lastSyncedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveCoverUrl(coverUrl: String): String {
|
||||||
|
if (coverUrl.isBlank()) return ""
|
||||||
|
val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl
|
||||||
|
val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/')
|
||||||
|
return when {
|
||||||
|
coverUrl.startsWith("http://", ignoreCase = true) ||
|
||||||
|
coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl
|
||||||
|
|
||||||
|
coverUrl.startsWith("/") -> "$base$coverUrl"
|
||||||
|
|
||||||
|
else -> "$base/$coverUrl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
|
||||||
Book(
|
Book(
|
||||||
id = 0,
|
id = 0,
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -83,6 +232,7 @@ 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(),
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,26 @@ 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
|
||||||
)
|
)
|
||||||
|
|
@ -211,6 +231,9 @@ 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) }
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ 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,
|
||||||
|
|
@ -59,6 +60,7 @@ data class Book(
|
||||||
remoteId = "",
|
remoteId = "",
|
||||||
libraryId = "",
|
libraryId = "",
|
||||||
mediaType = "",
|
mediaType = "",
|
||||||
|
itemType = "",
|
||||||
hasAudio = false,
|
hasAudio = false,
|
||||||
hasEbook = false,
|
hasEbook = false,
|
||||||
audioDuration = 0L,
|
audioDuration = 0L,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.model.reader
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ReaderPage(
|
||||||
|
val index: Int,
|
||||||
|
val items: List<ReaderText>,
|
||||||
|
val startTextIndex: Int,
|
||||||
|
val endTextIndex: Int,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.model.rsvp
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives RSVP playback for a fixed token stream.
|
||||||
|
*
|
||||||
|
* @param scope scope used for the playback loop (typically `viewModelScope`).
|
||||||
|
*/
|
||||||
|
class RsvpEngine(private val scope: CoroutineScope) {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val currentIndex: Int = 0,
|
||||||
|
val isPlaying: Boolean = false,
|
||||||
|
val tokens: List<RsvpToken> = emptyList(),
|
||||||
|
val wpm: Int = 350,
|
||||||
|
val pauseOnParagraphEnd: Boolean = true,
|
||||||
|
val pauseOnChapterEnd: Boolean = true,
|
||||||
|
val pauseOnLongWords: Boolean = true,
|
||||||
|
) {
|
||||||
|
val progress: Float
|
||||||
|
get() = if (tokens.isEmpty()) 0f
|
||||||
|
else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
|
||||||
|
|
||||||
|
val currentToken: RsvpToken?
|
||||||
|
get() = tokens.getOrNull(currentIndex)
|
||||||
|
|
||||||
|
val isAtEnd: Boolean
|
||||||
|
get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var playbackJob: Job? = null
|
||||||
|
|
||||||
|
fun setTokens(tokens: List<RsvpToken>) {
|
||||||
|
stop()
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
tokens = tokens,
|
||||||
|
currentIndex = 0,
|
||||||
|
isPlaying = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWpm(wpm: Int) {
|
||||||
|
_state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnParagraphEnd(value: Boolean) {
|
||||||
|
_state.value = _state.value.copy(pauseOnParagraphEnd = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnChapterEnd(value: Boolean) {
|
||||||
|
_state.value = _state.value.copy(pauseOnChapterEnd = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPauseOnLongWords(value: Boolean) {
|
||||||
|
_state.value = _state.value.copy(pauseOnLongWords = value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekToIndex(index: Int) {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.tokens.isEmpty()) return
|
||||||
|
val clamped = index.coerceIn(0, s.tokens.lastIndex)
|
||||||
|
_state.value = s.copy(currentIndex = clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekToProgress(progress: Float) {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.tokens.isEmpty()) return
|
||||||
|
val clamped = progress.coerceIn(0f, 1f)
|
||||||
|
val idx = (clamped * s.tokens.lastIndex).toInt()
|
||||||
|
_state.value = s.copy(currentIndex = idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipForward(count: Int = 10) {
|
||||||
|
seekToIndex(_state.value.currentIndex + count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun skipBackward(count: Int = 10) {
|
||||||
|
seekToIndex(_state.value.currentIndex - count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
val s = _state.value
|
||||||
|
if (s.tokens.isEmpty()) return
|
||||||
|
if (s.isAtEnd) {
|
||||||
|
seekToIndex(0)
|
||||||
|
}
|
||||||
|
if (_state.value.isPlaying) return
|
||||||
|
_state.value = _state.value.copy(isPlaying = true)
|
||||||
|
startPlaybackLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
_state.value = _state.value.copy(isPlaying = false)
|
||||||
|
playbackJob?.cancel()
|
||||||
|
playbackJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggle() {
|
||||||
|
if (_state.value.isPlaying) pause() else play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPlaybackLoop() {
|
||||||
|
playbackJob?.cancel()
|
||||||
|
playbackJob = scope.launch {
|
||||||
|
while (isActive && _state.value.isPlaying) {
|
||||||
|
val snapshot = _state.value
|
||||||
|
if (snapshot.isAtEnd) {
|
||||||
|
_state.value = snapshot.copy(isPlaying = false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = snapshot.tokens[snapshot.currentIndex]
|
||||||
|
delay(delayMsFor(token, snapshot))
|
||||||
|
|
||||||
|
val after = _state.value
|
||||||
|
if (!after.isPlaying) return@launch
|
||||||
|
if (after.tokens.isEmpty()) return@launch
|
||||||
|
|
||||||
|
_state.value = after.copy(
|
||||||
|
currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the delay (in ms) before advancing past [token].
|
||||||
|
*
|
||||||
|
* Base = 60_000 / wpm, adjusted by:
|
||||||
|
* - per-word multiplier (long words / digits)
|
||||||
|
* - paragraph-end bonus (configurable, default +200 ms)
|
||||||
|
* - chapter-end bonus (configurable, default +400 ms)
|
||||||
|
*/
|
||||||
|
private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
|
||||||
|
val wpm = snapshot.wpm.coerceAtLeast(50)
|
||||||
|
val baseMs = 60_000.0 / wpm
|
||||||
|
var ms = baseMs * token.multiplier
|
||||||
|
|
||||||
|
if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
|
||||||
|
ms += 200.0
|
||||||
|
}
|
||||||
|
if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
|
||||||
|
ms += 400.0
|
||||||
|
}
|
||||||
|
if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
|
||||||
|
// multiplier already applied above; keep it
|
||||||
|
} else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
|
||||||
|
ms = baseMs
|
||||||
|
}
|
||||||
|
return ms.toLong().coerceAtLeast(20L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.model.rsvp
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One word/segment in the RSVP stream.
|
||||||
|
*
|
||||||
|
* @param word the original word as it appears in the book.
|
||||||
|
* @param pivotIndex index of the optimal recognition point within [word];
|
||||||
|
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
|
||||||
|
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
|
||||||
|
* @param isChapterEnd true if the token follows a chapter heading.
|
||||||
|
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class RsvpToken(
|
||||||
|
val word: String,
|
||||||
|
val pivotIndex: Int,
|
||||||
|
val isParagraphEnd: Boolean,
|
||||||
|
val isChapterEnd: Boolean,
|
||||||
|
val multiplier: Float = 1f,
|
||||||
|
) {
|
||||||
|
val prefix: String
|
||||||
|
get() = word.substring(0, pivotIndex)
|
||||||
|
|
||||||
|
val pivot: String
|
||||||
|
get() = word.substring(pivotIndex, pivotIndex + 1)
|
||||||
|
|
||||||
|
val suffix: String
|
||||||
|
get() = word.substring(pivotIndex + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
|
||||||
|
* Indices are 0-based positions of the focal character.
|
||||||
|
* Values follow the heuristic table from Spritz/speed-reading literature:
|
||||||
|
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
||||||
|
*/
|
||||||
|
object OrpTable {
|
||||||
|
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
|
||||||
|
|
||||||
|
fun pivotFor(word: String): Int {
|
||||||
|
val len = word.length
|
||||||
|
if (len == 0) return 0
|
||||||
|
if (len <= TABLE.size) return TABLE[len - 1]
|
||||||
|
return (len * 0.3f).toInt().coerceIn(1, len - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.model.rsvp
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
|
||||||
|
* into a flat list of [RsvpToken]s suitable for RSVP playback.
|
||||||
|
*
|
||||||
|
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
|
||||||
|
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
|
||||||
|
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
|
||||||
|
* - Strips punctuation-only tokens and empty whitespace tokens.
|
||||||
|
*/
|
||||||
|
object RsvpTokenizer {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
|
||||||
|
*/
|
||||||
|
fun tokenizeReaderText(blocks: List<ReaderText>): List<RsvpToken> {
|
||||||
|
val tokens = ArrayList<RsvpToken>(blocks.size * 8)
|
||||||
|
var lastWasParagraphEnd = false
|
||||||
|
var pendingChapterEnd = false
|
||||||
|
|
||||||
|
for (block in blocks) {
|
||||||
|
when (block) {
|
||||||
|
is ReaderText.Chapter -> {
|
||||||
|
pendingChapterEnd = true
|
||||||
|
lastWasParagraphEnd = false
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Text -> {
|
||||||
|
val raw = block.line.text
|
||||||
|
if (raw.isBlank()) {
|
||||||
|
lastWasParagraphEnd = true
|
||||||
|
} else {
|
||||||
|
tokenizeLine(
|
||||||
|
line = raw,
|
||||||
|
isParagraphEnd = lastWasParagraphEnd,
|
||||||
|
isChapterEnd = pendingChapterEnd,
|
||||||
|
out = tokens,
|
||||||
|
)
|
||||||
|
lastWasParagraphEnd = false
|
||||||
|
pendingChapterEnd = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Separator -> lastWasParagraphEnd = true
|
||||||
|
is ReaderText.Image -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenizeLine(
|
||||||
|
line: String,
|
||||||
|
isParagraphEnd: Boolean,
|
||||||
|
isChapterEnd: Boolean,
|
||||||
|
out: MutableList<RsvpToken>,
|
||||||
|
) {
|
||||||
|
val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
|
||||||
|
if (words.isEmpty()) return
|
||||||
|
|
||||||
|
val lastIndex = words.lastIndex
|
||||||
|
for ((idx, word) in words.withIndex()) {
|
||||||
|
val cleaned = cleanWord(word)
|
||||||
|
if (cleaned.isEmpty()) continue
|
||||||
|
val pivot = OrpTable.pivotFor(cleaned)
|
||||||
|
val paragraphEnd = isParagraphEnd && idx == lastIndex
|
||||||
|
val multiplier = computeMultiplier(cleaned)
|
||||||
|
out.add(
|
||||||
|
RsvpToken(
|
||||||
|
word = cleaned,
|
||||||
|
pivotIndex = pivot,
|
||||||
|
isParagraphEnd = paragraphEnd,
|
||||||
|
isChapterEnd = isChapterEnd && idx == 0,
|
||||||
|
multiplier = multiplier,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val WORD_SPLIT_REGEX = Regex("\\s+")
|
||||||
|
|
||||||
|
private fun cleanWord(raw: String): String {
|
||||||
|
val sb = StringBuilder(raw.length)
|
||||||
|
var i = 0
|
||||||
|
val len = raw.length
|
||||||
|
while (i < len) {
|
||||||
|
val c = raw[i]
|
||||||
|
if (c.isLetterOrDigit() || c == '-' || c == '\'') {
|
||||||
|
sb.append(c)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-word pacing multiplier.
|
||||||
|
*
|
||||||
|
* - Long words (>9 chars) get +30% time for recognition.
|
||||||
|
* - Numbers/digits get +20% time.
|
||||||
|
* - Baseline 1.0 for normal words.
|
||||||
|
*/
|
||||||
|
private fun computeMultiplier(word: String): Float {
|
||||||
|
val len = word.length
|
||||||
|
val base = when {
|
||||||
|
len > 9 -> 1.3f
|
||||||
|
len > 6 -> 1.1f
|
||||||
|
else -> 1.0f
|
||||||
|
}
|
||||||
|
val hasDigit = word.any { it.isDigit() }
|
||||||
|
return if (hasDigit) base + 0.2f else base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,11 @@
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -13,4 +18,8 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,380 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.reader
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextLayoutResult
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||||
|
import org.dueattendant149.bookshelf.ui.reader.readerChapterTextStyle
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class PaginateReaderTextUseCase @Inject constructor() {
|
||||||
|
|
||||||
|
operator fun invoke(
|
||||||
|
text: List<ReaderText>,
|
||||||
|
contentWidthPx: Int,
|
||||||
|
contentHeightPx: Int,
|
||||||
|
paragraphStyle: TextStyle,
|
||||||
|
chapterTitleAlignment: ReaderTextAlignment,
|
||||||
|
paragraphSpacingPx: Int,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
imageMaxWidthPx: Int,
|
||||||
|
chapterKeepLines: Int = 2,
|
||||||
|
minWidowLines: Int = 2,
|
||||||
|
minOrphanLines: Int = 2,
|
||||||
|
safetyHeightPx: Int = 4,
|
||||||
|
chapterExtraHeightPx: Int = 0,
|
||||||
|
separatorHeightPx: Int = DEFAULT_SEPARATOR_HEIGHT_PX,
|
||||||
|
defaultImageHeightPx: Int = DEFAULT_IMAGE_HEIGHT_PX,
|
||||||
|
): List<ReaderPage> {
|
||||||
|
if (text.isEmpty() || contentWidthPx <= 0 || contentHeightPx <= 0) return emptyList()
|
||||||
|
|
||||||
|
val availableHeight = (contentHeightPx - safetyHeightPx).coerceAtLeast(1)
|
||||||
|
val workingBlocks = text.toMutableList()
|
||||||
|
val metrics = workingBlocks.map { block ->
|
||||||
|
measureBlock(
|
||||||
|
block = block,
|
||||||
|
contentWidthPx = contentWidthPx,
|
||||||
|
imageMaxWidthPx = imageMaxWidthPx,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
chapterTitleAlignment = chapterTitleAlignment,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||||
|
separatorHeightPx = separatorHeightPx,
|
||||||
|
defaultImageHeightPx = defaultImageHeightPx,
|
||||||
|
)
|
||||||
|
}.toMutableList()
|
||||||
|
|
||||||
|
val pages = mutableListOf<ReaderPage>()
|
||||||
|
val pageItems = mutableListOf<ReaderText>()
|
||||||
|
var startTextIndex = 0
|
||||||
|
var remainingHeight = availableHeight
|
||||||
|
var currentPageIndex = 0
|
||||||
|
var index = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the height of [metric] plus the inter-item spacing if this
|
||||||
|
* block is not the first one on the current page. This matches the
|
||||||
|
* [Arrangement.spacedBy] used in the paginated reader UI.
|
||||||
|
*/
|
||||||
|
fun heightWithSpacing(metric: BlockMetrics): Int {
|
||||||
|
return if (pageItems.isEmpty()) metric.height else metric.height + paragraphSpacingPx
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitPage(endTextIndex: Int) {
|
||||||
|
if (pageItems.isEmpty()) return
|
||||||
|
pages.add(
|
||||||
|
ReaderPage(
|
||||||
|
index = currentPageIndex,
|
||||||
|
items = pageItems.toList(),
|
||||||
|
startTextIndex = startTextIndex,
|
||||||
|
endTextIndex = endTextIndex,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
currentPageIndex++
|
||||||
|
pageItems.clear()
|
||||||
|
remainingHeight = availableHeight
|
||||||
|
startTextIndex = endTextIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
while (index < workingBlocks.size) {
|
||||||
|
val block = workingBlocks[index]
|
||||||
|
val metric = metrics[index]
|
||||||
|
val blockHeight = heightWithSpacing(metric)
|
||||||
|
|
||||||
|
when (block) {
|
||||||
|
is ReaderText.Chapter -> {
|
||||||
|
val keep = followingTextKeepHeight(
|
||||||
|
blocks = workingBlocks,
|
||||||
|
metrics = metrics,
|
||||||
|
startIndex = index + 1,
|
||||||
|
keepLines = chapterKeepLines,
|
||||||
|
)
|
||||||
|
// Heading itself + spacing after it (if anything follows) + kept text.
|
||||||
|
val requiredWithKeep = blockHeight +
|
||||||
|
(if (index < workingBlocks.lastIndex) paragraphSpacingPx else 0) +
|
||||||
|
keep
|
||||||
|
|
||||||
|
when {
|
||||||
|
requiredWithKeep > availableHeight -> {
|
||||||
|
// Heading + kept lines don't fit on a fresh page.
|
||||||
|
// Place heading alone.
|
||||||
|
if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
|
||||||
|
emitPage(index)
|
||||||
|
}
|
||||||
|
pageItems.add(block)
|
||||||
|
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredWithKeep > remainingHeight && pageItems.isNotEmpty() -> {
|
||||||
|
// Move heading to the next page so it stays with following text.
|
||||||
|
emitPage(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
pageItems.add(block)
|
||||||
|
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Separator,
|
||||||
|
is ReaderText.Image -> {
|
||||||
|
if (blockHeight > remainingHeight && pageItems.isNotEmpty()) {
|
||||||
|
emitPage(index)
|
||||||
|
}
|
||||||
|
pageItems.add(block)
|
||||||
|
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Text -> {
|
||||||
|
if (blockHeight <= remainingHeight) {
|
||||||
|
pageItems.add(block)
|
||||||
|
remainingHeight = (remainingHeight - blockHeight).coerceAtLeast(0)
|
||||||
|
index++
|
||||||
|
} else if (pageItems.isNotEmpty()) {
|
||||||
|
emitPage(index)
|
||||||
|
// Retry the same block on a fresh page.
|
||||||
|
} else {
|
||||||
|
// Fresh page and the paragraph still doesn't fit whole.
|
||||||
|
val split = splitParagraph(
|
||||||
|
block = block,
|
||||||
|
metric = metric,
|
||||||
|
availableHeight = remainingHeight,
|
||||||
|
minWidowLines = minWidowLines,
|
||||||
|
minOrphanLines = minOrphanLines,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (split == null) {
|
||||||
|
// Whole paragraph fits on a fresh page but we shouldn't be here.
|
||||||
|
// Safety fallback: place it anyway.
|
||||||
|
pageItems.add(block)
|
||||||
|
remainingHeight = 0
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
val (head, tail) = split
|
||||||
|
pageItems.add(ReaderText.Text(head))
|
||||||
|
emitPage(index + 1)
|
||||||
|
// The remainder is still the same logical block, so the next page
|
||||||
|
// should start at this index rather than index + 1.
|
||||||
|
startTextIndex = index
|
||||||
|
|
||||||
|
if (tail.isBlank()) {
|
||||||
|
index++
|
||||||
|
} else {
|
||||||
|
// Replace current block with remainder and retry on fresh page.
|
||||||
|
val remainderBlock = ReaderText.Text(tail)
|
||||||
|
workingBlocks[index] = remainderBlock
|
||||||
|
metrics[index] = measureBlock(
|
||||||
|
block = remainderBlock,
|
||||||
|
contentWidthPx = contentWidthPx,
|
||||||
|
imageMaxWidthPx = imageMaxWidthPx,
|
||||||
|
paragraphStyle = paragraphStyle,
|
||||||
|
chapterTitleAlignment = chapterTitleAlignment,
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
chapterExtraHeightPx = chapterExtraHeightPx,
|
||||||
|
separatorHeightPx = separatorHeightPx,
|
||||||
|
defaultImageHeightPx = defaultImageHeightPx,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageItems.isNotEmpty()) {
|
||||||
|
emitPage(workingBlocks.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d("Pagination", "Split ${text.size} blocks into ${pages.size} pages")
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun measureBlock(
|
||||||
|
block: ReaderText,
|
||||||
|
contentWidthPx: Int,
|
||||||
|
imageMaxWidthPx: Int,
|
||||||
|
paragraphStyle: TextStyle,
|
||||||
|
chapterTitleAlignment: ReaderTextAlignment,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
chapterExtraHeightPx: Int,
|
||||||
|
separatorHeightPx: Int,
|
||||||
|
defaultImageHeightPx: Int,
|
||||||
|
): BlockMetrics {
|
||||||
|
return when (block) {
|
||||||
|
is ReaderText.Text -> {
|
||||||
|
val layout = measureText(block.line, paragraphStyle, contentWidthPx, textMeasurer)
|
||||||
|
val textHeight = layout?.size?.height ?: 0
|
||||||
|
val lineCount = layout?.lineCount ?: 0
|
||||||
|
val lineHeights = (0 until lineCount).map { lineIndex ->
|
||||||
|
layout!!.getLineBottom(lineIndex)
|
||||||
|
}
|
||||||
|
BlockMetrics(
|
||||||
|
height = textHeight,
|
||||||
|
lineHeights = lineHeights,
|
||||||
|
isSplittable = true,
|
||||||
|
layout = layout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Chapter -> {
|
||||||
|
val chapterStyle = readerChapterTextStyle(
|
||||||
|
nested = block.nested,
|
||||||
|
textAlignment = chapterTitleAlignment,
|
||||||
|
)
|
||||||
|
val layout = measureText(
|
||||||
|
AnnotatedString(block.title),
|
||||||
|
chapterStyle,
|
||||||
|
contentWidthPx,
|
||||||
|
textMeasurer,
|
||||||
|
)
|
||||||
|
val titleHeight = layout?.size?.height ?: 0
|
||||||
|
BlockMetrics(
|
||||||
|
height = titleHeight + chapterExtraHeightPx,
|
||||||
|
isSplittable = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderText.Separator -> BlockMetrics(
|
||||||
|
height = separatorHeightPx,
|
||||||
|
isSplittable = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
is ReaderText.Image -> BlockMetrics(
|
||||||
|
height = imageHeight(block, imageMaxWidthPx, defaultImageHeightPx),
|
||||||
|
isSplittable = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the height of the first [keepLines] lines of the text block at
|
||||||
|
* [startIndex], or the full height of the block if it has fewer lines. If
|
||||||
|
* the next block is not splittable (e.g. an image), its full height is
|
||||||
|
* returned so the heading can be moved to the next page when necessary.
|
||||||
|
*/
|
||||||
|
private fun followingTextKeepHeight(
|
||||||
|
blocks: List<ReaderText>,
|
||||||
|
metrics: List<BlockMetrics>,
|
||||||
|
startIndex: Int,
|
||||||
|
keepLines: Int,
|
||||||
|
): Int {
|
||||||
|
if (startIndex >= blocks.size) return 0
|
||||||
|
val metric = metrics[startIndex]
|
||||||
|
val block = blocks[startIndex]
|
||||||
|
|
||||||
|
if (metric.lineHeights.isEmpty()) return metric.height
|
||||||
|
|
||||||
|
if (block !is ReaderText.Text) {
|
||||||
|
// Keep the whole non-text block with the heading.
|
||||||
|
return metric.height
|
||||||
|
}
|
||||||
|
|
||||||
|
val linesToKeep = minOf(keepLines, metric.lineHeights.size)
|
||||||
|
return metric.lineHeights[linesToKeep - 1].toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitParagraph(
|
||||||
|
block: ReaderText.Text,
|
||||||
|
metric: BlockMetrics,
|
||||||
|
availableHeight: Int,
|
||||||
|
minWidowLines: Int,
|
||||||
|
minOrphanLines: Int,
|
||||||
|
): Pair<AnnotatedString, AnnotatedString>? {
|
||||||
|
val layout = metric.layout ?: return null
|
||||||
|
val lineCount = layout.lineCount
|
||||||
|
if (lineCount == 0) return null
|
||||||
|
|
||||||
|
// Find the last line whose bottom fits inside the available height.
|
||||||
|
val lastFittingLine = layout.getLineForVerticalPosition(availableHeight.toFloat())
|
||||||
|
.coerceIn(0, lineCount - 1)
|
||||||
|
|
||||||
|
val maxLinesCurrent = lastFittingLine + 1
|
||||||
|
val minLinesCurrent = minWidowLines.coerceAtLeast(1)
|
||||||
|
val minLinesRemainder = minOrphanLines.coerceAtLeast(1)
|
||||||
|
|
||||||
|
// Ideal break: current page has at least minWidowLines,
|
||||||
|
// remainder has at least minOrphanLines.
|
||||||
|
val idealMax = (lineCount - minLinesRemainder).coerceAtLeast(minLinesCurrent)
|
||||||
|
val candidateLines = maxLinesCurrent.coerceIn(minLinesCurrent, idealMax)
|
||||||
|
|
||||||
|
if (candidateLines in minLinesCurrent..idealMax) {
|
||||||
|
val breakChar = layout.getLineEnd(candidateLines - 1, visibleEnd = true)
|
||||||
|
if (breakChar in 1 until block.line.length) {
|
||||||
|
return block.line.subSequence(0, breakChar) to
|
||||||
|
block.line.subSequence(breakChar, block.line.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph is longer than a full page or constraints cannot be satisfied.
|
||||||
|
// Split at the last line that actually fits.
|
||||||
|
val forcedLines = maxLinesCurrent.coerceIn(1, lineCount - 1)
|
||||||
|
val breakChar = layout.getLineEnd(forcedLines - 1, visibleEnd = true)
|
||||||
|
if (breakChar in 1 until block.line.length) {
|
||||||
|
return block.line.subSequence(0, breakChar) to
|
||||||
|
block.line.subSequence(breakChar, block.line.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun measureText(
|
||||||
|
text: AnnotatedString,
|
||||||
|
style: TextStyle,
|
||||||
|
maxWidthPx: Int,
|
||||||
|
textMeasurer: TextMeasurer,
|
||||||
|
): TextLayoutResult? {
|
||||||
|
if (text.isBlank()) return null
|
||||||
|
return try {
|
||||||
|
textMeasurer.measure(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
constraints = Constraints(maxWidth = maxWidthPx),
|
||||||
|
softWrap = true,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Pagination", "measure failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageHeight(
|
||||||
|
block: ReaderText.Image,
|
||||||
|
imageMaxWidthPx: Int,
|
||||||
|
defaultImageHeightPx: Int,
|
||||||
|
): Int {
|
||||||
|
val bitmap = block.imageBitmap
|
||||||
|
val width = bitmap.width
|
||||||
|
val height = bitmap.height
|
||||||
|
if (width <= 0 || height <= 0) return defaultImageHeightPx
|
||||||
|
return (imageMaxWidthPx * height / width).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class BlockMetrics(
|
||||||
|
val height: Int,
|
||||||
|
val lineHeights: List<Float> = emptyList(),
|
||||||
|
val isSplittable: Boolean = false,
|
||||||
|
val layout: TextLayoutResult? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DEFAULT_SEPARATOR_HEIGHT_PX = 12
|
||||||
|
private const val DEFAULT_IMAGE_HEIGHT_PX = 720
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GlobalSearchUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repository: RemoteLibraryRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(request: SearchRequest): Result<SearchResponse> =
|
||||||
|
repository.search(request)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ListDownloadsUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repository: RemoteLibraryRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): Result<DownloadsListResponse> =
|
||||||
|
repository.listDownloads()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class StartDownloadUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repository: RemoteLibraryRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(request: DownloadRequest): Result<DownloadResponse> =
|
||||||
|
repository.startDownload(request)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.RemoteLibraryRepository
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UploadBookUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val repository: RemoteLibraryRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(file: File, bookType: String): Result<String> {
|
||||||
|
val mediaType = file.name.let { name ->
|
||||||
|
when {
|
||||||
|
name.endsWith(".epub", ignoreCase = true) -> "application/epub+zip"
|
||||||
|
name.endsWith(".fb2", ignoreCase = true) -> "application/fb2+xml"
|
||||||
|
name.endsWith(".pdf", ignoreCase = true) -> "application/pdf"
|
||||||
|
name.endsWith(".mobi", ignoreCase = true) -> "application/x-mobipocket-ebook"
|
||||||
|
name.endsWith(".mp3", ignoreCase = true) -> "audio/mpeg"
|
||||||
|
name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> "audio/mp4"
|
||||||
|
name.endsWith(".zip", ignoreCase = true) -> "application/zip"
|
||||||
|
else -> "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filePart = MultipartBody.Part.createFormData(
|
||||||
|
"file",
|
||||||
|
file.name,
|
||||||
|
file.asRequestBody(mediaType.toMediaTypeOrNull()),
|
||||||
|
)
|
||||||
|
val typePart = bookType.toRequestBody("text/plain".toMediaTypeOrNull())
|
||||||
|
return repository.uploadBook(filePart, typePart).map { response ->
|
||||||
|
if (response.success) "Uploaded: ${response.filename}"
|
||||||
|
else response.errorMessage ?: "Upload failed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,12 +22,17 @@ fun String.isValidUri(): Boolean {
|
||||||
*/
|
*/
|
||||||
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
|
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
|
||||||
|
|
||||||
|
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds https:// if the string has no http/https scheme.
|
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
|
||||||
*/
|
*/
|
||||||
fun String.ensureUriScheme(): String {
|
fun String.ensureUriScheme(): String {
|
||||||
val trimmed = trim()
|
val trimmed = trim()
|
||||||
return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
|
if (trimmed.hasUriScheme()) return trimmed
|
||||||
|
val hostPart = trimmed.substringBefore("/")
|
||||||
|
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
|
||||||
|
return "$scheme$trimmed"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AudiobooksModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||||
|
fetchBooksUseCase: FetchBooksUseCase,
|
||||||
|
cacheBookUseCase: CacheBookUseCase,
|
||||||
|
deleteCacheUseCase: DeleteCacheUseCase,
|
||||||
|
getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||||
|
serverSettings: ServerSettings,
|
||||||
|
serverStatusMonitor: ServerStatusMonitor,
|
||||||
|
) : RemoteLibraryViewModel(
|
||||||
|
fetchLibrariesUseCase,
|
||||||
|
fetchBooksUseCase,
|
||||||
|
cacheBookUseCase,
|
||||||
|
deleteCacheUseCase,
|
||||||
|
getCacheStatusUseCase,
|
||||||
|
serverSettings,
|
||||||
|
serverStatusMonitor,
|
||||||
|
) {
|
||||||
|
override val libraryNameFilter: String? = "audiobook"
|
||||||
|
override val itemTypeFilter: String? = "audiobook"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
||||||
|
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||||
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object AudiobooksScreen : Screen, Parcelable {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val model = hiltViewModel<AudiobooksModel>()
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
|
LaunchedEffect(model.effects, navigator) {
|
||||||
|
model.effects.collectLatest { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is RemoteLibraryEffect.OnNavigateToReader -> {
|
||||||
|
HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
|
||||||
|
navigator.push(ReaderScreen(effect.bookId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteLibraryContent(
|
||||||
|
model = model,
|
||||||
|
titleRes = R.string.audiobooks_screen,
|
||||||
|
onPlayAudio = { book ->
|
||||||
|
navigator.push(PlayerScreen(book))
|
||||||
|
},
|
||||||
|
navigateToTts = { bookRemoteId ->
|
||||||
|
// Not used for audiobooks; secondary button opens chapters.
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BooksModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||||
|
fetchBooksUseCase: FetchBooksUseCase,
|
||||||
|
cacheBookUseCase: CacheBookUseCase,
|
||||||
|
deleteCacheUseCase: DeleteCacheUseCase,
|
||||||
|
getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||||
|
serverSettings: ServerSettings,
|
||||||
|
serverStatusMonitor: ServerStatusMonitor,
|
||||||
|
) : RemoteLibraryViewModel(
|
||||||
|
fetchLibrariesUseCase,
|
||||||
|
fetchBooksUseCase,
|
||||||
|
cacheBookUseCase,
|
||||||
|
deleteCacheUseCase,
|
||||||
|
getCacheStatusUseCase,
|
||||||
|
serverSettings,
|
||||||
|
serverStatusMonitor,
|
||||||
|
) {
|
||||||
|
override val libraryNameFilter: String? = "book"
|
||||||
|
override val itemTypeFilter: String? = "book"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.player.PlayerScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.tts.TtsScreen
|
||||||
|
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
|
||||||
|
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
|
||||||
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object BooksScreen : Screen, Parcelable {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val model = hiltViewModel<BooksModel>()
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
|
LaunchedEffect(model.effects, navigator) {
|
||||||
|
model.effects.collectLatest { effect ->
|
||||||
|
when (effect) {
|
||||||
|
is RemoteLibraryEffect.OnNavigateToReader -> {
|
||||||
|
HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
|
||||||
|
navigator.push(ReaderScreen(effect.bookId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteLibraryContent(
|
||||||
|
model = model,
|
||||||
|
titleRes = R.string.books_screen,
|
||||||
|
onPlayAudio = { book ->
|
||||||
|
navigator.push(PlayerScreen(book))
|
||||||
|
},
|
||||||
|
navigateToTts = { bookRemoteId ->
|
||||||
|
navigator.push(TtsScreen(bookRemoteId))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,23 +6,9 @@
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.bookshelf
|
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 kotlinx.coroutines.Dispatchers
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||||
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
|
||||||
|
|
@ -30,145 +16,26 @@ 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(
|
||||||
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||||
private val fetchBooksUseCase: FetchBooksUseCase,
|
fetchBooksUseCase: FetchBooksUseCase,
|
||||||
private val cacheBookUseCase: CacheBookUseCase,
|
cacheBookUseCase: CacheBookUseCase,
|
||||||
private val deleteCacheUseCase: DeleteCacheUseCase,
|
deleteCacheUseCase: DeleteCacheUseCase,
|
||||||
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||||
private val serverSettings: ServerSettings,
|
serverSettings: ServerSettings,
|
||||||
) : ViewModel() {
|
serverStatusMonitor: ServerStatusMonitor,
|
||||||
private val _state = MutableStateFlow(RemoteLibraryState())
|
) : RemoteLibraryViewModel(
|
||||||
val state = _state.asStateFlow()
|
fetchLibrariesUseCase,
|
||||||
private var cacheStatusJob: Job? = null
|
fetchBooksUseCase,
|
||||||
|
cacheBookUseCase,
|
||||||
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
deleteCacheUseCase,
|
||||||
val effects = _effects.asSharedFlow()
|
getCacheStatusUseCase,
|
||||||
|
serverSettings,
|
||||||
init {
|
serverStatusMonitor,
|
||||||
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,6 +12,7 @@ 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.player.PlayerScreen
|
||||||
|
|
@ -41,6 +42,7 @@ object RemoteLibraryScreen : Screen, Parcelable {
|
||||||
|
|
||||||
RemoteLibraryContent(
|
RemoteLibraryContent(
|
||||||
model = model,
|
model = model,
|
||||||
|
titleRes = R.string.bookshelf_screen,
|
||||||
onPlayAudio = { book ->
|
onPlayAudio = { book ->
|
||||||
navigator.push(PlayerScreen(book))
|
navigator.push(PlayerScreen(book))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared UI state for remote-library tabs.
|
||||||
|
*/
|
||||||
|
data class RemoteLibraryState(
|
||||||
|
val libraries: List<RemoteLibrary> = emptyList(),
|
||||||
|
val books: List<Book> = emptyList(),
|
||||||
|
val selectedLibrary: RemoteLibrary? = null,
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
||||||
|
val offlineOnly: Boolean = false,
|
||||||
|
val cachingBookId: String? = null,
|
||||||
|
val serverOnline: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base ViewModel for tabs that display remote libraries filtered by media type.
|
||||||
|
*
|
||||||
|
* @param mediaTypeFilter null means no filtering (legacy "bookshelf" view).
|
||||||
|
*/
|
||||||
|
abstract class RemoteLibraryViewModel(
|
||||||
|
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||||
|
private val fetchBooksUseCase: FetchBooksUseCase,
|
||||||
|
private val cacheBookUseCase: CacheBookUseCase,
|
||||||
|
private val deleteCacheUseCase: DeleteCacheUseCase,
|
||||||
|
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
||||||
|
private val serverSettings: ServerSettings,
|
||||||
|
private val serverStatusMonitor: ServerStatusMonitor,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to pick the default library by name when the backend has multiple libraries.
|
||||||
|
*/
|
||||||
|
protected abstract val libraryNameFilter: String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to split the unified item list into Books vs Audiobooks tabs.
|
||||||
|
*/
|
||||||
|
protected abstract val itemTypeFilter: String?
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RemoteLibraryState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
private var cacheStatusJob: Job? = null
|
||||||
|
|
||||||
|
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLibraries()
|
||||||
|
viewModelScope.launch {
|
||||||
|
serverStatusMonitor.isOnline.collect { online ->
|
||||||
|
_state.update {
|
||||||
|
if (it.serverOnline == online) it
|
||||||
|
else it.copy(serverOnline = online, offlineOnly = !online || it.offlineOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadLibraries() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
if (!serverSettings.hasCredentials()) {
|
||||||
|
_state.update { it.copy(isLoading = false, error = "Server not configured") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val result = fetchLibrariesUseCase()
|
||||||
|
val allLibraries = result.getOrDefault(emptyList())
|
||||||
|
val libraries = allLibraries.filter { matchesLibraryFilter(it) }
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
libraries = libraries,
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraries.isNotEmpty() && state.value.selectedLibrary == null) {
|
||||||
|
selectLibrary(libraries.first())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches library to filter by name pattern (e.g. "Books" vs "Audiobooks").
|
||||||
|
* The unified endpoint returns mixed items, so we no longer filter libraries by mediaType.
|
||||||
|
*/
|
||||||
|
private fun matchesLibraryFilter(library: RemoteLibrary): Boolean {
|
||||||
|
val filter = libraryNameFilter ?: return true
|
||||||
|
val name = library.name.lowercase()
|
||||||
|
return when (filter) {
|
||||||
|
"book" -> name.contains("book") && !name.contains("audio")
|
||||||
|
"audiobook" -> name.contains("audio")
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectLibrary(library: RemoteLibrary) {
|
||||||
|
_state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
|
||||||
|
loadBooks(library.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBooks(libraryId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
val result = fetchBooksUseCase(libraryId)
|
||||||
|
val allBooks = result.getOrDefault(emptyList())
|
||||||
|
val books = if (itemTypeFilter != null) {
|
||||||
|
allBooks.filter { matchesItemTypeFilter(it) }
|
||||||
|
} else {
|
||||||
|
allBooks
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
books = books,
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
observeCacheStatuses()
|
||||||
|
|
||||||
|
if (state.value.serverOnline) {
|
||||||
|
autoCacheEbooks(books)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun autoCacheEbooks(books: List<Book>) {
|
||||||
|
// Auto-cache on tab open is disabled by design. Caching now happens
|
||||||
|
// only when the user explicitly opens a book or presses download.
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesItemTypeFilter(book: Book): Boolean {
|
||||||
|
val filter = itemTypeFilter ?: return true
|
||||||
|
return when (filter) {
|
||||||
|
"book" -> book.itemType == "ebook" || book.itemType == "hybrid"
|
||||||
|
"audiobook" -> book.itemType == "audiobook" || book.itemType == "podcast" || book.itemType == "hybrid"
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheBook(book: Book) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
|
val result = cacheBookUseCase(book)
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
|
if (result.isFailure) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeCacheStatuses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCache(book: Book) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = deleteCacheUseCase(book.remoteId)
|
||||||
|
if (result.isFailure) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleOfflineOnly() {
|
||||||
|
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openBook(book: Book) {
|
||||||
|
if (!book.hasEbook || book.remoteId.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
|
val result = cacheBookUseCase(book)
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
|
|
||||||
|
result
|
||||||
|
.onSuccess { bookId ->
|
||||||
|
if (bookId <= 0) {
|
||||||
|
_state.update { it.copy(error = "Could not open book: invalid id") }
|
||||||
|
return@onSuccess
|
||||||
|
}
|
||||||
|
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
|
||||||
|
}
|
||||||
|
.onFailure { error ->
|
||||||
|
_state.update { it.copy(error = error.message ?: "Could not open book") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts caching an audiobook so it can be played offline.
|
||||||
|
*/
|
||||||
|
fun cacheAudiobook(book: Book) {
|
||||||
|
if (!book.hasAudio || book.remoteId.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
|
val result = cacheBookUseCase(book)
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
|
if (result.isFailure) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(error = result.exceptionOrNull()?.message ?: "Could not cache audiobook")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observeCacheStatuses()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeCacheStatuses() {
|
||||||
|
cacheStatusJob?.cancel()
|
||||||
|
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
||||||
|
if (remoteIds.isEmpty()) {
|
||||||
|
_state.update { it.copy(cacheStatuses = emptyMap()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheStatusJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
combine(
|
||||||
|
remoteIds.map { remoteId ->
|
||||||
|
getCacheStatusUseCase(remoteId)
|
||||||
|
},
|
||||||
|
) { statuses ->
|
||||||
|
statuses.toList().associateBy { it.remoteId }
|
||||||
|
}.collect { statuses ->
|
||||||
|
_state.update { it.copy(cacheStatuses = statuses) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,13 +24,10 @@ import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import 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.browse.BrowseModel
|
import org.dueattendant149.bookshelf.presentation.bookshelf.BooksScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.browse.BrowseScreen
|
import org.dueattendant149.bookshelf.presentation.bookshelf.AudiobooksScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryModel
|
import org.dueattendant149.bookshelf.presentation.search.SearchScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
import org.dueattendant149.bookshelf.presentation.settings.SettingsScreen
|
||||||
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
|
||||||
|
|
@ -77,11 +74,6 @@ 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
|
||||||
)
|
)
|
||||||
|
|
@ -89,32 +81,32 @@ class MainActivity : AppCompatActivity() {
|
||||||
ProvideSettings(settings) {
|
ProvideSettings(settings) {
|
||||||
val tabs = persistentListOf(
|
val tabs = persistentListOf(
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = LibraryScreen,
|
screen = BooksScreen,
|
||||||
title = R.string.library_screen,
|
title = R.string.books_screen,
|
||||||
tooltip = R.string.library_content_desc,
|
tooltip = R.string.books_content_desc,
|
||||||
selectedIcon = R.drawable.library_screen_filled,
|
selectedIcon = R.drawable.book_tab_filled,
|
||||||
unselectedIcon = R.drawable.library_screen_outlined
|
unselectedIcon = R.drawable.book_tab_outlined
|
||||||
),
|
),
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = HistoryScreen,
|
screen = AudiobooksScreen,
|
||||||
title = R.string.history_screen,
|
title = R.string.audiobooks_screen,
|
||||||
tooltip = R.string.history_content_desc,
|
tooltip = R.string.audiobooks_content_desc,
|
||||||
selectedIcon = R.drawable.history_screen_filled,
|
selectedIcon = R.drawable.audiobook_tab_filled,
|
||||||
unselectedIcon = R.drawable.history_screen_outlined
|
unselectedIcon = R.drawable.audiobook_tab_outlined
|
||||||
),
|
),
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = BrowseScreen,
|
screen = SearchScreen,
|
||||||
title = R.string.browse_screen,
|
title = R.string.search_screen,
|
||||||
tooltip = R.string.browse_content_desc,
|
tooltip = R.string.search_content_desc_network,
|
||||||
selectedIcon = R.drawable.browse_screen_filled,
|
selectedIcon = R.drawable.search_tab_filled,
|
||||||
unselectedIcon = R.drawable.browse_screen_outlined
|
unselectedIcon = R.drawable.search_tab_outlined
|
||||||
),
|
),
|
||||||
NavigatorItem(
|
NavigatorItem(
|
||||||
screen = RemoteLibraryScreen,
|
screen = SettingsScreen,
|
||||||
title = R.string.bookshelf_screen,
|
title = R.string.settings_screen,
|
||||||
tooltip = R.string.bookshelf_content_desc,
|
tooltip = R.string.settings_screen,
|
||||||
selectedIcon = R.drawable.bookshelf_screen_filled,
|
selectedIcon = R.drawable.settings_tab_filled,
|
||||||
unselectedIcon = R.drawable.bookshelf_screen_outlined
|
unselectedIcon = R.drawable.settings_tab_outlined
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -129,7 +121,7 @@ class MainActivity : AppCompatActivity() {
|
||||||
) {
|
) {
|
||||||
Navigator(
|
Navigator(
|
||||||
initialScreen = if (settings.showStartScreen.value) StartScreen
|
initialScreen = if (settings.showStartScreen.value) StartScreen
|
||||||
else LibraryScreen,
|
else BooksScreen,
|
||||||
transitionSpec = { lastEvent ->
|
transitionSpec = { lastEvent ->
|
||||||
when (lastEvent) {
|
when (lastEvent) {
|
||||||
StackEvent.DEFAULT -> {
|
StackEvent.DEFAULT -> {
|
||||||
|
|
@ -145,14 +137,14 @@ class MainActivity : AppCompatActivity() {
|
||||||
},
|
},
|
||||||
contentKey = {
|
contentKey = {
|
||||||
when (it) {
|
when (it) {
|
||||||
LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> "tabs"
|
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> "tabs"
|
||||||
else -> it
|
else -> it
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
backHandlerEnabled = { it != StartScreen }
|
backHandlerEnabled = { it != StartScreen }
|
||||||
) { screen ->
|
) { screen ->
|
||||||
when (screen) {
|
when (screen) {
|
||||||
LibraryScreen, HistoryScreen, BrowseScreen, RemoteLibraryScreen -> {
|
BooksScreen, AudiobooksScreen, SearchScreen, SettingsScreen -> {
|
||||||
NavigatorTabs(
|
NavigatorTabs(
|
||||||
currentTab = screen,
|
currentTab = screen,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ class Navigator @AssistedInject constructor(
|
||||||
popping: Boolean = false,
|
popping: Boolean = false,
|
||||||
saveInBackStack: Boolean = true
|
saveInBackStack: Boolean = true
|
||||||
) {
|
) {
|
||||||
if (lastItem.value::class == targetScreen::class) return
|
val current = lastItem.value
|
||||||
|
if (current::class == targetScreen::class && current == targetScreen) return
|
||||||
if (!saveInBackStack) items.removeLast()
|
if (!saveInBackStack) items.removeLast()
|
||||||
|
|
||||||
changeStackEvent(
|
changeStackEvent(
|
||||||
|
|
@ -60,7 +61,6 @@ class Navigator @AssistedInject constructor(
|
||||||
else StackEvent.DEFAULT
|
else StackEvent.DEFAULT
|
||||||
)
|
)
|
||||||
|
|
||||||
if (lastItem.value::class == targetScreen::class) items.removeLast()
|
|
||||||
items.add(targetScreen)
|
items.add(targetScreen)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
package org.dueattendant149.bookshelf.presentation.player
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class ChapterListScreen(val book: Book) : Screen, Parcelable {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val model = hiltViewModel<PlayerModel>()
|
||||||
|
val state = model.state.collectAsStateWithLifecycle().value
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(book.title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { navigator.pop() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.go_back_content_desc)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.tracks) { index, track ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
|
navigator.push(PlayerScreen(book, startTrackIndex = index))
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}. ${track.title}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatMs(track.durationMs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMs(ms: Long): String {
|
||||||
|
val totalSeconds = ms / 1000
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return if (hours > 0) {
|
||||||
|
String.format("%d:%02d:%02d", hours, minutes, seconds)
|
||||||
|
} else {
|
||||||
|
String.format("%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.presentation.player
|
package org.dueattendant149.bookshelf.presentation.player
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
|
@ -15,40 +14,88 @@ import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
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.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.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
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.playback.AudioPlaybackService
|
import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
|
||||||
import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
|
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.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.player.AudioTrack
|
||||||
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
|
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.use_case.remote.SyncPlaybackProgressUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlayerModel
|
class PlayerModel
|
||||||
@OptIn(UnstableApi::class)
|
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val exoPlayer: ExoPlayer,
|
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
private val audiobookshelfRepository: AudiobookshelfRepository,
|
private val audiobookshelfRepository: AudiobookshelfRepository,
|
||||||
private val authProvider: PlaybackAuthProvider,
|
|
||||||
private val database: BookDatabase,
|
private val database: BookDatabase,
|
||||||
private val bookMapper: BookMapper,
|
private val bookMapper: BookMapper,
|
||||||
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
|
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
|
||||||
|
private val cacheBookUseCase: CacheBookUseCase,
|
||||||
|
private val okHttpClient: okhttp3.OkHttpClient,
|
||||||
|
private val authProvider: PlaybackAuthProvider,
|
||||||
|
private val simpleCache: SimpleCache,
|
||||||
) : ViewModel() {
|
) : 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())
|
private val _state = MutableStateFlow(PlayerState())
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -97,24 +144,22 @@ class PlayerModel
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
exoPlayer.addListener(listener)
|
|
||||||
startProgressUpdates()
|
startProgressUpdates()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun init(book: Book) {
|
fun init(book: Book, startTrackIndex: Int = 0) {
|
||||||
if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank()) return
|
if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank() && startTrackIndex == 0) return
|
||||||
_state.update { it.copy(book = book, isLoading = true, error = null) }
|
_state.update { it.copy(book = book, isLoading = true, error = null) }
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val url = serverSettings.getAbsUrl()?.fixUriScheme()
|
authProvider.refresh(serverSettings)
|
||||||
val token = serverSettings.getAbsToken()
|
|
||||||
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme()
|
||||||
_state.update { it.copy(isLoading = false, error = "Audiobookshelf not configured") }
|
if (baseUrl.isNullOrBlank()) {
|
||||||
|
_state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
authProvider.token = token
|
|
||||||
|
|
||||||
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
|
||||||
tracksResult.fold(
|
tracksResult.fold(
|
||||||
onSuccess = { tracks ->
|
onSuccess = { tracks ->
|
||||||
|
|
@ -123,7 +168,9 @@ class PlayerModel
|
||||||
return@fold
|
return@fold
|
||||||
}
|
}
|
||||||
_state.update { it.copy(tracks = tracks) }
|
_state.update { it.copy(tracks = tracks) }
|
||||||
loadTracks(url, tracks)
|
val startIndex = startTrackIndex.coerceIn(0, tracks.lastIndex)
|
||||||
|
val startPosition = if (startIndex == 0) book.audioCurrentPosition else 0L
|
||||||
|
loadTracks(baseUrl, tracks, startIndex, startPosition)
|
||||||
startService(book)
|
startService(book)
|
||||||
},
|
},
|
||||||
onFailure = { error ->
|
onFailure = { error ->
|
||||||
|
|
@ -133,9 +180,20 @@ class PlayerModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadTracks(baseUrl: String, tracks: List<org.dueattendant149.bookshelf.domain.model.player.AudioTrack>) {
|
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 mediaItems = tracks.map { track ->
|
||||||
val uri = "${baseUrl}api/items/${_state.value.book.remoteId}/file/${track.fileId}"
|
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()
|
MediaItem.Builder()
|
||||||
.setMediaId(track.fileId)
|
.setMediaId(track.fileId)
|
||||||
.setUri(uri)
|
.setUri(uri)
|
||||||
|
|
@ -149,7 +207,7 @@ class PlayerModel
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
exoPlayer.setMediaItems(mediaItems)
|
exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||||
exoPlayer.prepare()
|
exoPlayer.prepare()
|
||||||
exoPlayer.play()
|
exoPlayer.play()
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +317,28 @@ class PlayerModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
override fun onCleared() {
|
||||||
exoPlayer.removeListener(listener)
|
exoPlayer.removeListener(listener)
|
||||||
exoPlayer.stop()
|
exoPlayer.stop()
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,17 @@ import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
import org.dueattendant149.bookshelf.ui.player.PlayerContent
|
import org.dueattendant149.bookshelf.ui.player.PlayerContent
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class PlayerScreen(val book: Book) : Screen, Parcelable {
|
data class PlayerScreen(
|
||||||
|
val book: Book,
|
||||||
|
val startTrackIndex: Int = 0,
|
||||||
|
) : Screen, Parcelable {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
val model = hiltViewModel<PlayerModel>()
|
val model = hiltViewModel<PlayerModel>()
|
||||||
|
|
||||||
LaunchedEffect(book) {
|
LaunchedEffect(book, startTrackIndex) {
|
||||||
model.init(book)
|
model.init(book, startTrackIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
|
|
@ -33,6 +36,7 @@ data class PlayerScreen(val book: Book) : Screen, Parcelable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val navigator = org.dueattendant149.bookshelf.ui.navigator.LocalNavigator.current
|
||||||
PlayerContent(
|
PlayerContent(
|
||||||
state = model.state,
|
state = model.state,
|
||||||
onPlayPause = model::playPause,
|
onPlayPause = model::playPause,
|
||||||
|
|
@ -43,6 +47,8 @@ data class PlayerScreen(val book: Book) : Screen, Parcelable {
|
||||||
onSkipPrevious = model::skipToPrevious,
|
onSkipPrevious = model::skipToPrevious,
|
||||||
onSetSpeed = model::setSpeed,
|
onSetSpeed = model::setSpeed,
|
||||||
onSelectTrack = model::selectTrack,
|
onSelectTrack = model::selectTrack,
|
||||||
|
onOpenChapters = { navigator.push(ChapterListScreen(book)) },
|
||||||
|
onDownloadAudiobook = model::downloadAudiobook,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ data class PlayerState(
|
||||||
val currentTrackIndex: Int = 0,
|
val currentTrackIndex: Int = 0,
|
||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
|
val isCaching: Boolean = false,
|
||||||
|
val cacheProgress: Float = 0f,
|
||||||
val currentPositionMs: Long = 0L,
|
val currentPositionMs: Long = 0L,
|
||||||
val durationMs: Long = 0L,
|
val durationMs: Long = 0L,
|
||||||
val bufferedPositionMs: Long = 0L,
|
val bufferedPositionMs: Long = 0L,
|
||||||
|
|
|
||||||
|
|
@ -38,4 +38,8 @@ 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,6 +40,14 @@ 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()
|
||||||
|
|
@ -78,4 +86,8 @@ 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,6 +37,7 @@ 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.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
|
||||||
|
|
@ -53,6 +54,7 @@ class ReaderModel @Inject constructor(
|
||||||
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
||||||
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
||||||
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
||||||
|
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -96,7 +98,9 @@ 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()
|
||||||
|
|
@ -111,11 +115,20 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
is ReaderEvent.OnRestoreScroll -> {
|
is ReaderEvent.OnRestoreScroll -> {
|
||||||
snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 }
|
val state = _state.value
|
||||||
|
if (state.pages.isNotEmpty()) {
|
||||||
|
val page = state.pages
|
||||||
|
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
onEvent(ReaderEvent.OnChangePage(page))
|
||||||
|
_state.update { it.copy(isLoading = false, errorMessage = null) }
|
||||||
|
} else {
|
||||||
|
snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 }
|
||||||
|
|
||||||
_state.value.listState.requestScrollToItem(
|
state.listState.requestScrollToItem(
|
||||||
index = _state.value.book.scrollIndex,
|
index = state.book.scrollIndex,
|
||||||
scrollOffset = _state.value.book.scrollOffset
|
scrollOffset = state.book.scrollOffset
|
||||||
)
|
)
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
|
|
@ -131,6 +144,7 @@ class ReaderModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is ReaderEvent.OnMenuVisibility -> {
|
is ReaderEvent.OnMenuVisibility -> {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
|
@ -230,16 +244,71 @@ class ReaderModel @Inject constructor(
|
||||||
scrollJob = viewModelScope.launch(Dispatchers.IO) {
|
scrollJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
delay(300)
|
delay(300)
|
||||||
|
|
||||||
val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
|
val state = _state.value
|
||||||
|
if (state.pages.isNotEmpty()) {
|
||||||
_state.value.listState.requestScrollToItem(
|
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,
|
index = scrollTo,
|
||||||
scrollOffset = 0
|
scrollOffset = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
|
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderEvent.OnChangePage -> {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val state = _state.value
|
||||||
|
val pages = state.pages
|
||||||
|
if (pages.isEmpty()) return@withContext
|
||||||
|
val page = event.page.coerceIn(0, pages.lastIndex)
|
||||||
|
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
|
||||||
|
|
||||||
|
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
|
||||||
|
pageStart,
|
||||||
|
state.text
|
||||||
|
)
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
currentPage = page,
|
||||||
|
currentChapter = currentChapter,
|
||||||
|
currentChapterProgress = currentChapterProgress,
|
||||||
|
book = it.book.copy(
|
||||||
|
progress = calculateProgress(page),
|
||||||
|
scrollIndex = pageStart,
|
||||||
|
scrollOffset = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBookUseCase(_state.value.book)
|
||||||
|
syncReadingProgressUseCase(
|
||||||
|
_state.value.book,
|
||||||
|
_state.value.currentChapter?.title,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ReaderEvent.OnPagesComputed -> {
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
val pages = event.pages
|
||||||
|
if (pages.isEmpty()) {
|
||||||
|
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
// Restore the page that contains the saved scrollIndex.
|
||||||
|
val savedIndex = _state.value.book.scrollIndex
|
||||||
|
val targetPage = pages
|
||||||
|
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
|
||||||
|
.takeIf { it >= 0 }
|
||||||
|
?: 0
|
||||||
|
_state.update { it.copy(pages = pages, currentPage = targetPage) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
is ReaderEvent.OnRestoreCheckpoint -> {
|
is ReaderEvent.OnRestoreCheckpoint -> {
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
|
|
@ -384,6 +453,10 @@ 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) }
|
||||||
}
|
}
|
||||||
|
|
@ -484,24 +557,33 @@ class ReaderModel @Inject constructor(
|
||||||
} ?: (-1 to -1)
|
} ?: (-1 to -1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
|
private fun calculateProgress(pageOrIndex: Int? = null): Float {
|
||||||
|
val state = _state.value
|
||||||
if (
|
if (
|
||||||
_state.value.isLoading ||
|
state.isLoading ||
|
||||||
_state.value.listState.layoutInfo.totalItemsCount == 0 ||
|
state.text.isEmpty() ||
|
||||||
_state.value.text.isEmpty() ||
|
state.errorMessage != null
|
||||||
_state.value.errorMessage != null
|
) return state.book.progress
|
||||||
) return _state.value.book.progress
|
|
||||||
|
|
||||||
if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f
|
if (state.pages.isNotEmpty()) {
|
||||||
|
if (state.pages.size <= 1) return 0f
|
||||||
val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index
|
val page = pageOrIndex ?: state.currentPage
|
||||||
if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f
|
return page.coerceIn(0, state.pages.lastIndex)
|
||||||
|
.div(state.pages.lastIndex.toFloat())
|
||||||
return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex)
|
|
||||||
.div(_state.value.text.lastIndex.toFloat())
|
|
||||||
.coerceAndPreventNaN()
|
.coerceAndPreventNaN()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress
|
||||||
|
|
||||||
|
val index = pageOrIndex ?: state.listState.firstVisibleItemIndex
|
||||||
|
if (index == 0) return 0f
|
||||||
|
|
||||||
|
val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
|
||||||
|
if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
|
||||||
|
|
||||||
|
return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
|
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
coroutineContext.ensureActive()
|
coroutineContext.ensureActive()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ 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
|
||||||
|
|
@ -23,19 +24,29 @@ 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
|
||||||
|
|
@ -55,6 +66,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
|
import org.dueattendant149.bookshelf.ui.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
|
||||||
|
|
@ -83,6 +95,11 @@ 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 {
|
||||||
|
|
@ -155,76 +172,9 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -287,6 +237,158 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recompute pages when text or relevant settings change.
|
||||||
|
val pages by produceState(
|
||||||
|
initialValue = emptyList<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>(),
|
||||||
|
state.value.text,
|
||||||
|
readerPagination,
|
||||||
|
settings.fontSize.value,
|
||||||
|
settings.lineHeight.value,
|
||||||
|
settings.sidePadding.value,
|
||||||
|
settings.paragraphHeight.value,
|
||||||
|
settings.textAlignment.value,
|
||||||
|
settings.fontFamily.value,
|
||||||
|
settings.fontThickness.value,
|
||||||
|
settings.italic.value,
|
||||||
|
settings.letterSpacing.value,
|
||||||
|
settings.paragraphIndentation.value,
|
||||||
|
settings.progressBarPadding.value,
|
||||||
|
settings.progressBarFontSize.value,
|
||||||
|
settings.imagesWidth.value,
|
||||||
|
settings.fullscreen.value,
|
||||||
|
settings.cutoutPadding.value,
|
||||||
|
configuration.screenWidthDp,
|
||||||
|
configuration.screenHeightDp,
|
||||||
|
) {
|
||||||
|
value = withContext(Dispatchers.Default) {
|
||||||
|
if (!readerPagination || state.value.text.isEmpty()) {
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
val contentPaddingTopPx = with(density) {
|
||||||
|
contentPadding.calculateTopPadding().toPx()
|
||||||
|
}.toInt()
|
||||||
|
val contentPaddingBottomPx = with(density) {
|
||||||
|
contentPadding.calculateBottomPadding().toPx()
|
||||||
|
}.toInt()
|
||||||
|
val contentPaddingStartPx = with(density) {
|
||||||
|
contentPadding.calculateStartPadding(layoutDirection).toPx()
|
||||||
|
}.toInt()
|
||||||
|
val contentPaddingEndPx = with(density) {
|
||||||
|
contentPadding.calculateEndPadding(layoutDirection).toPx()
|
||||||
|
}.toInt()
|
||||||
|
|
||||||
|
val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
|
||||||
|
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
|
||||||
|
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
|
||||||
|
|
||||||
|
val contentWidthPx = with(density) {
|
||||||
|
configuration.screenWidthDp.dp.toPx()
|
||||||
|
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
|
||||||
|
|
||||||
|
val progressBarHeightPx = with(density) {
|
||||||
|
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentHeightPx = with(density) {
|
||||||
|
configuration.screenHeightDp.dp.toPx()
|
||||||
|
}.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx
|
||||||
|
|
||||||
|
val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt()
|
||||||
|
|
||||||
|
val style = readerParagraphTextStyle(
|
||||||
|
fontFamily = settings.fontFamily.lastValue,
|
||||||
|
fontThickness = settings.fontThickness.lastValue,
|
||||||
|
fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal,
|
||||||
|
textAlignment = settings.textAlignment.lastValue,
|
||||||
|
fontSize = settings.fontSize.lastValue.sp,
|
||||||
|
lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp,
|
||||||
|
letterSpacing = (settings.letterSpacing.lastValue / 100f).em,
|
||||||
|
paragraphIndentation = paragraphIndentation,
|
||||||
|
)
|
||||||
|
org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke(
|
||||||
|
text = state.value.text,
|
||||||
|
contentWidthPx = contentWidthPx,
|
||||||
|
contentHeightPx = contentHeightPx,
|
||||||
|
paragraphStyle = style,
|
||||||
|
chapterTitleAlignment = settings.chapterTitleAlignment.lastValue,
|
||||||
|
paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(),
|
||||||
|
textMeasurer = textMeasurer,
|
||||||
|
imageMaxWidthPx = imageMaxWidthPx,
|
||||||
|
chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(),
|
||||||
|
separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val perceptionExpanderPadding = remember(
|
||||||
|
sidePadding,
|
||||||
|
settings.perceptionExpanderPadding.value
|
||||||
|
) {
|
||||||
|
sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
|
||||||
|
}
|
||||||
|
val perceptionExpanderThickness = remember(
|
||||||
|
settings.perceptionExpanderThickness.value
|
||||||
|
) {
|
||||||
|
(settings.perceptionExpanderThickness.lastValue * 0.25f).dp
|
||||||
|
}
|
||||||
|
val horizontalLimiterHeight = remember(
|
||||||
|
settings.horizontalLimiterHeight.value
|
||||||
|
) {
|
||||||
|
(settings.horizontalLimiterHeight.lastValue * 20).dp
|
||||||
|
}
|
||||||
|
val horizontalLimiterRulerThickness = remember(
|
||||||
|
settings.horizontalLimiterRulerThickness.value
|
||||||
|
) {
|
||||||
|
(settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
|
||||||
|
}
|
||||||
|
val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
|
||||||
|
(36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
|
||||||
|
}
|
||||||
|
val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
|
||||||
|
when (settings.highlightedReadingThickness.lastValue) {
|
||||||
|
2 -> FontWeight.SemiBold
|
||||||
|
3 -> FontWeight.Bold
|
||||||
|
else -> FontWeight.Medium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val horizontalAlignment = remember(settings.textAlignment.value) {
|
||||||
|
when (settings.textAlignment.lastValue) {
|
||||||
|
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
|
||||||
|
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
|
||||||
|
ReaderTextAlignment.END -> Alignment.End
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val imagesCornersRoundness = remember(
|
||||||
|
settings.imagesCornersRoundness.value,
|
||||||
|
settings.imagesWidth.value
|
||||||
|
) {
|
||||||
|
(settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
|
||||||
|
}
|
||||||
|
val imagesColorEffects = remember(
|
||||||
|
settings.imagesColorEffects.value,
|
||||||
|
fontColor.value,
|
||||||
|
backgroundColor.value
|
||||||
|
) {
|
||||||
|
when (settings.imagesColorEffects.lastValue) {
|
||||||
|
ReaderColorEffects.OFF -> null
|
||||||
|
|
||||||
|
ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
|
||||||
|
ColorMatrix().apply { setToSaturation(0f) }
|
||||||
|
)
|
||||||
|
|
||||||
|
ReaderColorEffects.FONT -> ColorFilter.tint(
|
||||||
|
color = fontColor.value,
|
||||||
|
blendMode = BlendMode.Color
|
||||||
|
)
|
||||||
|
|
||||||
|
ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
|
||||||
|
color = backgroundColor.value,
|
||||||
|
blendMode = BlendMode.Color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
val bottomBarPadding = remember(settings.bottomBarPadding.value) {
|
val bottomBarPadding = remember(settings.bottomBarPadding.value) {
|
||||||
(settings.bottomBarPadding.lastValue * 4f).dp
|
(settings.bottomBarPadding.lastValue * 4f).dp
|
||||||
}
|
}
|
||||||
|
|
@ -336,6 +438,9 @@ 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(
|
||||||
|
|
@ -372,11 +477,45 @@ 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()
|
||||||
|
|
@ -393,6 +532,10 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
fullscreen = settings.fullscreen.value
|
fullscreen = settings.fullscreen.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.focusable()
|
||||||
|
) {
|
||||||
ReaderContent(
|
ReaderContent(
|
||||||
book = state.value.book,
|
book = state.value.book,
|
||||||
text = state.value.text,
|
text = state.value.text,
|
||||||
|
|
@ -417,6 +560,14 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
checkpoints = state.value.checkpoints,
|
checkpoints = state.value.checkpoints,
|
||||||
showMenu = state.value.showMenu,
|
showMenu = state.value.showMenu,
|
||||||
lockMenu = state.value.lockMenu,
|
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,
|
contentPadding = contentPadding,
|
||||||
verticalPadding = verticalPadding,
|
verticalPadding = verticalPadding,
|
||||||
horizontalGesture = settings.horizontalGesture.value,
|
horizontalGesture = settings.horizontalGesture.value,
|
||||||
|
|
@ -470,7 +621,9 @@ 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,6 +12,7 @@ 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
|
||||||
|
|
@ -20,6 +21,8 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||||
|
|
||||||
|
sealed class RsvpEffect {
|
||||||
|
data object OnNavigateBack : RsvpEffect()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||||
|
|
||||||
|
sealed class RsvpEvent {
|
||||||
|
data object OnPlayPause : RsvpEvent()
|
||||||
|
data object OnStop : RsvpEvent()
|
||||||
|
data object OnClose : RsvpEvent()
|
||||||
|
data class OnWpmChange(val wpm: Int) : RsvpEvent()
|
||||||
|
data class OnSeekToProgress(val progress: Float) : RsvpEvent()
|
||||||
|
data class OnSkip(val delta: Int) : RsvpEvent()
|
||||||
|
data object OnToggleControls : RsvpEvent()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.SettingsManager
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpEngine
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpTokenizer
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.book.GetBookUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RsvpModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val getBookUseCase: GetBookUseCase,
|
||||||
|
private val getTextUseCase: GetTextUseCase,
|
||||||
|
private val updateBookUseCase: UpdateBookUseCase,
|
||||||
|
private val settings: SettingsManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val engine = RsvpEngine(viewModelScope)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RsvpState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _effects = MutableSharedFlow<RsvpEffect>()
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
|
val engineState = engine.state
|
||||||
|
|
||||||
|
fun init(bookId: Int) {
|
||||||
|
viewModelScope.launch(Dispatchers.Default) {
|
||||||
|
val book = getBookUseCase(bookId)
|
||||||
|
if (book == null) {
|
||||||
|
_state.update { it.copy(isLoading = false, errorMessage = "Book not found") }
|
||||||
|
_effects.emit(RsvpEffect.OnNavigateBack)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update { it.copy(book = book, isLoading = true, errorMessage = null) }
|
||||||
|
|
||||||
|
val text = getTextUseCase(bookId)
|
||||||
|
if (text.isEmpty()) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = "Текст недоступен"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val tokens = withContext(Dispatchers.Default) {
|
||||||
|
RsvpTokenizer.tokenizeReaderText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens.isEmpty()) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(isLoading = false, errorMessage = "Книга пуста")
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val startWpm = settings.rsvpWpm.lastValue.takeIf { it in 50..1500 } ?: 350
|
||||||
|
val pauseOnParagraph = settings.rsvpPauseOnParagraph.lastValue
|
||||||
|
val pauseOnChapter = settings.rsvpPauseOnChapter.lastValue
|
||||||
|
val pauseOnLong = settings.rsvpPauseOnLongWords.lastValue
|
||||||
|
|
||||||
|
engine.setWpm(startWpm)
|
||||||
|
engine.setPauseOnParagraphEnd(pauseOnParagraph)
|
||||||
|
engine.setPauseOnChapterEnd(pauseOnChapter)
|
||||||
|
engine.setPauseOnLongWords(pauseOnLong)
|
||||||
|
engine.setTokens(tokens)
|
||||||
|
|
||||||
|
val startIndex = (book.progress * (tokens.lastIndex.coerceAtLeast(1)))
|
||||||
|
.roundToInt()
|
||||||
|
.coerceIn(0, tokens.lastIndex)
|
||||||
|
engine.seekToIndex(startIndex)
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
book = book,
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: RsvpEvent) {
|
||||||
|
when (event) {
|
||||||
|
RsvpEvent.OnPlayPause -> engine.toggle()
|
||||||
|
RsvpEvent.OnStop -> engine.stop()
|
||||||
|
RsvpEvent.OnClose -> {
|
||||||
|
persistProgressAndClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
is RsvpEvent.OnWpmChange -> {
|
||||||
|
val clamped = event.wpm.coerceIn(50, 1500)
|
||||||
|
engine.setWpm(clamped)
|
||||||
|
settings.rsvpWpm.update(clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
is RsvpEvent.OnSeekToProgress -> {
|
||||||
|
engine.seekToProgress(event.progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
is RsvpEvent.OnSkip -> {
|
||||||
|
if (event.delta >= 0) engine.skipForward(event.delta)
|
||||||
|
else engine.skipBackward(-event.delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
RsvpEvent.OnToggleControls -> {
|
||||||
|
_state.update { it.copy(showControls = !it.showControls) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persistProgressAndClose() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val es = engine.state.value
|
||||||
|
val currentBook = state.value.book
|
||||||
|
if (es.tokens.isNotEmpty() && currentBook.id != 0) {
|
||||||
|
val newProgress = es.currentIndex.toFloat() /
|
||||||
|
es.tokens.lastIndex.coerceAtLeast(1).toFloat()
|
||||||
|
updateBookUseCase(
|
||||||
|
currentBook.copy(
|
||||||
|
progress = newProgress.coerceIn(0f, 1f),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
engine.stop()
|
||||||
|
_effects.emit(RsvpEffect.OnNavigateBack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
engine.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
import org.dueattendant149.bookshelf.ui.rsvp.RsvpContent
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class RsvpScreen(val bookId: Int) : Screen, Parcelable {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val model = hiltViewModel<RsvpModel>()
|
||||||
|
val navigator = LocalNavigator.current
|
||||||
|
|
||||||
|
LaunchedEffect(bookId) {
|
||||||
|
model.init(bookId)
|
||||||
|
}
|
||||||
|
LaunchedEffect(model.effects, navigator) {
|
||||||
|
model.effects.collectLatest { effect ->
|
||||||
|
when (effect) {
|
||||||
|
RsvpEffect.OnNavigateBack -> navigator.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RsvpContent(model = model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.rsvp
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class RsvpState(
|
||||||
|
val book: Book = Book.default,
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val showControls: Boolean = true,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.search
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.GlobalSearchUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.ListDownloadsUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.StartDownloadUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.UploadBookUseCase
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class SearchState(
|
||||||
|
val query: String = "",
|
||||||
|
val results: List<SearchResultItemResponse> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
val downloadingTitle: String? = null,
|
||||||
|
val downloadSuccess: String? = null,
|
||||||
|
val downloadProgresses: Map<String, DownloadStatusResponse> = emptyMap(),
|
||||||
|
val uploadSuccess: String? = null,
|
||||||
|
val isUploading: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SearchModel
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val globalSearchUseCase: GlobalSearchUseCase,
|
||||||
|
private val startDownloadUseCase: StartDownloadUseCase,
|
||||||
|
private val listDownloadsUseCase: ListDownloadsUseCase,
|
||||||
|
private val uploadBookUseCase: UploadBookUseCase,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(SearchState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var pollJob: Job? = null
|
||||||
|
|
||||||
|
fun setQuery(query: String) {
|
||||||
|
_state.update { it.copy(query = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search() {
|
||||||
|
val query = state.value.query.trim()
|
||||||
|
if (query.isBlank()) return
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null, results = emptyList()) }
|
||||||
|
val result = globalSearchUseCase(SearchRequest(query = query))
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
results = result.getOrDefault(SearchResponse(emptyList())).results,
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startDownload(item: SearchResultItemResponse) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(downloadingTitle = item.title, downloadSuccess = null) }
|
||||||
|
val request = DownloadRequest(
|
||||||
|
source = item.source,
|
||||||
|
title = item.title,
|
||||||
|
author = item.author,
|
||||||
|
downloadUrl = item.downloadUrl.ifBlank { null },
|
||||||
|
magnetUrl = item.magnetUrl.ifBlank { null },
|
||||||
|
infoHash = item.infoHash.ifBlank { null },
|
||||||
|
md5 = item.md5.ifBlank { null },
|
||||||
|
url = item.url.ifBlank { null },
|
||||||
|
mediaType = item.mediaType.ifBlank { "ebook" },
|
||||||
|
downloadProtocol = item.downloadProtocol,
|
||||||
|
)
|
||||||
|
val result = startDownloadUseCase(request)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
downloadingTitle = null,
|
||||||
|
downloadSuccess = if (result.isSuccess) item.title else null,
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result.isSuccess) {
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun uploadBook(file: File, bookType: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isUploading = true, uploadSuccess = null, error = null) }
|
||||||
|
val result = uploadBookUseCase(file, bookType)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isUploading = false,
|
||||||
|
uploadSuccess = result.getOrNull(),
|
||||||
|
error = result.exceptionOrNull()?.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearUploadSuccess() {
|
||||||
|
_state.update { it.copy(uploadSuccess = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearDownloadSuccess() {
|
||||||
|
_state.update { it.copy(downloadSuccess = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
pollJob?.cancel()
|
||||||
|
pollJob = viewModelScope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(3000L)
|
||||||
|
val result = listDownloadsUseCase()
|
||||||
|
result.onSuccess { list ->
|
||||||
|
val map = list.downloads.associateBy { it.title }
|
||||||
|
_state.update { it.copy(downloadProgresses = map) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
pollJob?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.presentation.search
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
||||||
|
import org.dueattendant149.bookshelf.ui.search.SearchContent
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
object SearchScreen : Screen, Parcelable {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val model = hiltViewModel<SearchModel>()
|
||||||
|
SearchContent(model = model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ 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
|
||||||
|
|
@ -29,9 +30,12 @@ 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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,36 +8,25 @@ 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.Row
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
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.icons.filled.PlayArrow
|
|
||||||
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
|
||||||
|
|
@ -51,39 +40,62 @@ 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.RemoteLibraryModel
|
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
|
import org.dueattendant149.bookshelf.ui.common.components.placeholder.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,
|
onPlayAudio: (Book) -> Unit,
|
||||||
navigateToTts: (bookRemoteId: String) -> 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 visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
|
val sortedBooks = remember(state.books) {
|
||||||
if (!state.offlineOnly) state.books
|
state.books.sortedByDescending { it.lastOpened ?: 0L }
|
||||||
else state.books.filter { book ->
|
}
|
||||||
val status = state.cacheStatuses[book.remoteId]
|
|
||||||
status?.state == CacheState.COMPLETED ||
|
val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) {
|
||||||
status?.state == CacheState.DOWNLOADING ||
|
if (!state.offlineOnly) sortedBooks
|
||||||
status?.state == CacheState.PENDING
|
else sortedBooks.filter { book ->
|
||||||
|
when (state.cacheStatuses[book.remoteId]?.state) {
|
||||||
|
CacheState.COMPLETED,
|
||||||
|
CacheState.DOWNLOADING,
|
||||||
|
CacheState.PENDING -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val onlineIndicator = remember(state.serverOnline) {
|
||||||
|
if (state.serverOnline) ""
|
||||||
|
else " • Offline"
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets(0),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.bookshelf_screen)) }
|
title = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(titleRes) + onlineIndicator,
|
||||||
|
style = MaterialTheme.typography.titleLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
|
@ -98,97 +110,71 @@ fun RemoteLibraryContent(
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
items(
|
||||||
Text(
|
items = visibleBooks,
|
||||||
text = stringResource(R.string.bookshelf_libraries_header),
|
key = { it.remoteId }
|
||||||
style = MaterialTheme.typography.titleSmall,
|
) { book ->
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(state.libraries.size) { index ->
|
|
||||||
val library = state.libraries[index]
|
|
||||||
val selected = state.selectedLibrary?.id == library.id
|
|
||||||
Card(
|
|
||||||
onClick = { model.selectLibrary(library) },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
|
||||||
text = library.name,
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.bookshelf_library_subtitle,
|
|
||||||
library.mediaType,
|
|
||||||
library.itemCount
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = {
|
|
||||||
if (selected) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.bookshelf_selected_label),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.selectedLibrary?.let { library ->
|
|
||||||
item {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.bookshelf_books_header, library.name),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.bookshelf_offline_only_label),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = state.offlineOnly,
|
|
||||||
onCheckedChange = { model.toggleOfflineOnly() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
items(visibleBooks.size) { index ->
|
|
||||||
val book = visibleBooks[index]
|
|
||||||
val cacheStatus = state.cacheStatuses[book.remoteId]
|
val cacheStatus = state.cacheStatuses[book.remoteId]
|
||||||
val isCaching = state.cachingBookId == book.remoteId
|
val isCaching = state.cachingBookId == book.remoteId
|
||||||
BookListItem(
|
|
||||||
|
val progress = when {
|
||||||
|
book.hasAudio -> {
|
||||||
|
val dur = book.audioDuration.coerceAtLeast(1L)
|
||||||
|
(book.audioCurrentPosition.toFloat() / dur).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
else -> book.progress.coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
val primaryLabel: String
|
||||||
|
val secondaryLabel: String
|
||||||
|
val onPrimaryClick: () -> Unit
|
||||||
|
val onSecondaryClick: () -> Unit
|
||||||
|
|
||||||
|
when {
|
||||||
|
book.hasAudio -> {
|
||||||
|
primaryLabel = stringResource(R.string.bookshelf_play_audio_action)
|
||||||
|
secondaryLabel = stringResource(R.string.chapters_button)
|
||||||
|
onPrimaryClick = { onPlayAudio(book) }
|
||||||
|
onSecondaryClick = { navigator.push(ChapterListScreen(book)) }
|
||||||
|
}
|
||||||
|
book.hasEbook -> {
|
||||||
|
primaryLabel = stringResource(R.string.read)
|
||||||
|
secondaryLabel = stringResource(R.string.tts_button)
|
||||||
|
onPrimaryClick = { model.openBook(book) }
|
||||||
|
onSecondaryClick = { navigateToTts(book.remoteId) }
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
primaryLabel = ""
|
||||||
|
secondaryLabel = ""
|
||||||
|
onPrimaryClick = {}
|
||||||
|
onSecondaryClick = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BookCard(
|
||||||
book = book,
|
book = book,
|
||||||
cacheStatus = cacheStatus,
|
progress = progress,
|
||||||
isCaching = isCaching,
|
primaryButton = {
|
||||||
onDownload = { model.cacheBook(book) },
|
if (primaryLabel.isNotBlank()) {
|
||||||
onDelete = { model.deleteCache(book) },
|
PrimaryActionButton(
|
||||||
onRead = if (book.hasEbook) ({ model.openBook(book) }) else null,
|
label = primaryLabel,
|
||||||
onPlayAudio = if (book.hasAudio) ({ onPlayAudio(book) }) else null,
|
enabled = !isCaching,
|
||||||
onTts = { navigateToTts(book.remoteId) }
|
onClick = onPrimaryClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secondaryButton = {
|
||||||
|
if (secondaryLabel.isNotBlank()) {
|
||||||
|
SecondaryActionButton(
|
||||||
|
label = secondaryLabel,
|
||||||
|
onClick = onSecondaryClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
|
if (state.isLoading && state.books.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -202,7 +188,7 @@ fun RemoteLibraryContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
|
if (!state.isLoading && state.books.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),
|
||||||
|
|
@ -212,6 +198,7 @@ fun RemoteLibraryContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
state.error?.let { error ->
|
state.error?.let { error ->
|
||||||
ErrorPlaceholder(
|
ErrorPlaceholder(
|
||||||
modifier = Modifier.align(Alignment.Center),
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
|
@ -233,122 +220,4 @@ fun RemoteLibraryContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun BookListItem(
|
|
||||||
book: Book,
|
|
||||||
cacheStatus: CacheStatus?,
|
|
||||||
isCaching: Boolean,
|
|
||||||
onDownload: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onRead: (() -> Unit)?,
|
|
||||||
onPlayAudio: (() -> Unit)?,
|
|
||||||
onTts: () -> 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 (book.hasAudio) {
|
|
||||||
IconButton(onClick = onPlayAudio ?: {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.PlayArrow,
|
|
||||||
contentDescription = stringResource(R.string.bookshelf_play_audio_action)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onRead != null || onPlayAudio != null) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(bottom = 12.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
|
||||||
) {
|
|
||||||
Button(onClick = onTts) {
|
|
||||||
Text(stringResource(R.string.tts_button))
|
|
||||||
}
|
|
||||||
onRead?.let { read ->
|
|
||||||
Button(
|
|
||||||
onClick = read,
|
|
||||||
enabled = !isCaching
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.read))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onPlayAudio?.let { play ->
|
|
||||||
Button(onClick = play) {
|
|
||||||
Text(stringResource(R.string.bookshelf_play_audio_action))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun cacheStatusLabel(state: CacheState): String =
|
|
||||||
when (state) {
|
|
||||||
CacheState.NONE -> ""
|
|
||||||
CacheState.PENDING -> stringResource(R.string.bookshelf_cache_pending)
|
|
||||||
CacheState.DOWNLOADING -> stringResource(R.string.bookshelf_cache_downloading)
|
|
||||||
CacheState.COMPLETED -> stringResource(R.string.bookshelf_cache_completed)
|
|
||||||
CacheState.FAILED -> stringResource(R.string.bookshelf_cache_failed)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,184 @@
|
||||||
|
package org.dueattendant149.bookshelf.ui.common.components.book
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import coil.request.ImageRequest
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BookCard(
|
||||||
|
book: Book,
|
||||||
|
progress: Float,
|
||||||
|
primaryButton: @Composable (() -> Unit)? = null,
|
||||||
|
secondaryButton: @Composable (() -> Unit)? = null,
|
||||||
|
coverBadge: (@Composable () -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
val coercedProgress = progress.coerceIn(0f, 1f)
|
||||||
|
Card(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.shadow(elevation = 4.dp, shape = shape, clip = false),
|
||||||
|
shape = shape,
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Fixed-height vertical cover on the left
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(120.dp)
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (book.coverUrl.isNotBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = ImageRequest.Builder(LocalContext.current)
|
||||||
|
.data(Uri.parse(book.coverUrl))
|
||||||
|
.crossfade(200)
|
||||||
|
.build(),
|
||||||
|
contentDescription = stringResource(R.string.cover_image_content_desc),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CoverPlaceholder(book = book)
|
||||||
|
}
|
||||||
|
coverBadge?.invoke()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Title / author / progress in the middle
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = book.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = book.author.getAsString() ?: stringResource(R.string.unknown_author),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { coercedProgress },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp)),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
// Two pill buttons stacked vertically on the right
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
secondaryButton?.invoke()
|
||||||
|
primaryButton?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverPlaceholder(book: Book) {
|
||||||
|
val seed = remember(book.title, book.author.getAsString()) {
|
||||||
|
book.title + book.author.getAsString()
|
||||||
|
}
|
||||||
|
val color = rememberCoverColor(seed = seed)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(color),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = book.title.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberCoverColor(seed: String): Color {
|
||||||
|
val palette = remember {
|
||||||
|
listOf(
|
||||||
|
Color(0xFF1E88E5),
|
||||||
|
Color(0xFF43A047),
|
||||||
|
Color(0xFFE53935),
|
||||||
|
Color(0xFF8E24AA),
|
||||||
|
Color(0xFFFDD835),
|
||||||
|
Color(0xFF00897B),
|
||||||
|
Color(0xFF3949AB),
|
||||||
|
Color(0xFFD81B60),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val index = (seed.hashCode() % palette.size).let { if (it < 0) it + palette.size else it }
|
||||||
|
return palette[index]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
package org.dueattendant149.bookshelf.ui.common.components.book
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PrimaryActionButton(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 84.dp)
|
||||||
|
.height(40.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SecondaryActionButton(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 84.dp)
|
||||||
|
.height(40.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.Pause
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.SkipNext
|
import androidx.compose.material.icons.filled.SkipNext
|
||||||
|
|
@ -28,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
|
@ -63,6 +65,8 @@ fun PlayerContent(
|
||||||
onSkipPrevious: () -> Unit,
|
onSkipPrevious: () -> Unit,
|
||||||
onSetSpeed: (Float) -> Unit,
|
onSetSpeed: (Float) -> Unit,
|
||||||
onSelectTrack: (Int) -> Unit,
|
onSelectTrack: (Int) -> Unit,
|
||||||
|
onOpenChapters: () -> Unit,
|
||||||
|
onDownloadAudiobook: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val playerState by state.collectAsStateWithLifecycle()
|
val playerState by state.collectAsStateWithLifecycle()
|
||||||
val navigator = LocalNavigator.current
|
val navigator = LocalNavigator.current
|
||||||
|
|
@ -121,9 +125,10 @@ fun PlayerContent(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
val duration = playerState.durationMs.coerceAtLeast(1L)
|
val duration = playerState.durationMs.coerceAtLeast(1L)
|
||||||
var sliderPosition by remember(playerState.currentPositionMs) {
|
val positionFraction = remember(playerState.currentPositionMs, duration) {
|
||||||
mutableFloatStateOf(playerState.currentPositionMs / duration.toFloat())
|
(playerState.currentPositionMs / duration.toFloat()).coerceIn(0f, 1f)
|
||||||
}
|
}
|
||||||
|
var sliderPosition by remember(positionFraction) { mutableFloatStateOf(positionFraction) }
|
||||||
Slider(
|
Slider(
|
||||||
value = sliderPosition,
|
value = sliderPosition,
|
||||||
onValueChange = { sliderPosition = it },
|
onValueChange = { sliderPosition = it },
|
||||||
|
|
@ -192,13 +197,50 @@ fun PlayerContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
itemsIndexed(playerState.tracks) { index, track ->
|
itemsIndexed(
|
||||||
|
items = playerState.tracks,
|
||||||
|
key = { _, track -> track.fileId }
|
||||||
|
) { index, track ->
|
||||||
val selected = index == playerState.currentTrackIndex
|
val selected = index == playerState.currentTrackIndex
|
||||||
Card(
|
Card(
|
||||||
onClick = { onSelectTrack(index) },
|
onClick = { onSelectTrack(index) },
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
|
||||||
import org.dueattendant149.bookshelf.ui.common.model.Direction
|
import org.dueattendant149.bookshelf.ui.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(
|
||||||
|
|
@ -60,6 +61,10 @@ 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
|
||||||
|
|
@ -142,6 +147,10 @@ fun ReaderBottomBar(
|
||||||
book = book,
|
book = book,
|
||||||
lockMenu = lockMenu,
|
lockMenu = lockMenu,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
|
readerPagination = readerPagination,
|
||||||
|
pages = pages,
|
||||||
|
currentPage = currentPage,
|
||||||
|
onChangePage = onChangePage,
|
||||||
scroll = scroll,
|
scroll = scroll,
|
||||||
changeProgress = changeProgress
|
changeProgress = changeProgress
|
||||||
)
|
)
|
||||||
|
|
@ -240,6 +249,10 @@ 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
|
||||||
) {
|
) {
|
||||||
|
|
@ -255,7 +268,17 @@ private fun ReaderBottomBarSlider(
|
||||||
value = progress,
|
value = progress,
|
||||||
enabled = !lockMenu,
|
enabled = !lockMenu,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
if (listState.layoutInfo.totalItemsCount > 0) {
|
if (readerPagination && pages.isNotEmpty()) {
|
||||||
|
val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex)
|
||||||
|
onChangePage(page)
|
||||||
|
changeProgress(
|
||||||
|
ReaderEvent.OnChangeProgress(
|
||||||
|
progress = it,
|
||||||
|
firstVisibleItemIndex = pages[page].startTextIndex,
|
||||||
|
firstVisibleItemOffset = 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (listState.layoutInfo.totalItemsCount > 0) {
|
||||||
scroll(ReaderEvent.OnScroll(it))
|
scroll(ReaderEvent.OnScroll(it))
|
||||||
changeProgress(
|
changeProgress(
|
||||||
ReaderEvent.OnChangeProgress(
|
ReaderEvent.OnChangeProgress(
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,10 @@ 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,
|
||||||
|
|
@ -111,6 +115,7 @@ 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(
|
||||||
|
|
@ -141,6 +146,10 @@ fun ReaderContent(
|
||||||
checkpoints = checkpoints,
|
checkpoints = checkpoints,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
lockMenu = lockMenu,
|
lockMenu = lockMenu,
|
||||||
|
readerPagination = readerPagination,
|
||||||
|
pages = pages,
|
||||||
|
currentPage = currentPage,
|
||||||
|
onPageChanged = onPageChanged,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalPadding = verticalPadding,
|
verticalPadding = verticalPadding,
|
||||||
horizontalGesture = horizontalGesture,
|
horizontalGesture = horizontalGesture,
|
||||||
|
|
@ -191,7 +200,8 @@ 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,6 +19,7 @@ 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
|
||||||
|
|
@ -183,6 +184,10 @@ fun ReaderEffects(
|
||||||
saveInBackStack = false
|
saveInBackStack = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ReaderEffect.OnNavigateToRsvp -> {
|
||||||
|
navigator.push(RsvpScreen(bookId = effect.bookId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,18 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.TextMeasurer
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.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.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import androidx.compose.ui.unit.coerceAtLeast
|
import androidx.compose.ui.unit.coerceAtLeast
|
||||||
|
|
@ -51,6 +56,10 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
||||||
@Composable
|
@Composable
|
||||||
fun ReaderLayout(
|
fun ReaderLayout(
|
||||||
text: List<ReaderText>,
|
text: List<ReaderText>,
|
||||||
|
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
|
||||||
|
currentPage: Int,
|
||||||
|
readerPagination: Boolean,
|
||||||
|
onPageChanged: (Int) -> Unit,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
verticalPadding: Dp,
|
verticalPadding: Dp,
|
||||||
|
|
@ -97,6 +106,9 @@ fun ReaderLayout(
|
||||||
openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit
|
openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit
|
||||||
) {
|
) {
|
||||||
val activity = LocalActivity.current
|
val activity = LocalActivity.current
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
|
||||||
SelectionContainer(
|
SelectionContainer(
|
||||||
onCopyRequested = {
|
onCopyRequested = {
|
||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||||
|
|
@ -139,7 +151,7 @@ fun ReaderLayout(
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.then(
|
.then(
|
||||||
if (!isLoading && toolbarHidden) {
|
if (!isLoading && toolbarHidden && !readerPagination) {
|
||||||
Modifier.noRippleClickable(
|
Modifier.noRippleClickable(
|
||||||
onClick = {
|
onClick = {
|
||||||
menuVisibility(
|
menuVisibility(
|
||||||
|
|
@ -154,7 +166,9 @@ fun ReaderLayout(
|
||||||
)
|
)
|
||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
.padding(vertical = verticalPadding)
|
.padding(vertical = verticalPadding)
|
||||||
.readerHorizontalGesture(
|
.then(
|
||||||
|
if (!readerPagination) {
|
||||||
|
Modifier.readerHorizontalGesture(
|
||||||
listState = listState,
|
listState = listState,
|
||||||
horizontalGesture = horizontalGesture,
|
horizontalGesture = horizontalGesture,
|
||||||
horizontalGestureScroll = horizontalGestureScroll,
|
horizontalGestureScroll = horizontalGestureScroll,
|
||||||
|
|
@ -163,7 +177,54 @@ fun ReaderLayout(
|
||||||
horizontalGesturePullAnim = horizontalGesturePullAnim,
|
horizontalGesturePullAnim = horizontalGesturePullAnim,
|
||||||
isLoading = isLoading
|
isLoading = isLoading
|
||||||
)
|
)
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
|
if (readerPagination && pages.isNotEmpty()) {
|
||||||
|
ReaderPaginationLayout(
|
||||||
|
pages = pages,
|
||||||
|
currentPage = currentPage,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
verticalPadding = verticalPadding,
|
||||||
|
highlightedReading = highlightedReading,
|
||||||
|
highlightedReadingThickness = highlightedReadingThickness,
|
||||||
|
progress = progress,
|
||||||
|
progressBar = progressBar,
|
||||||
|
progressBarPadding = progressBarPadding,
|
||||||
|
progressBarAlignment = progressBarAlignment,
|
||||||
|
progressBarFontSize = progressBarFontSize,
|
||||||
|
paragraphHeight = paragraphHeight,
|
||||||
|
sidePadding = sidePadding,
|
||||||
|
bottomBarPadding = 0.dp,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
fontColor = fontColor,
|
||||||
|
images = images,
|
||||||
|
imagesCaptions = imagesCaptions,
|
||||||
|
imagesCornersRoundness = imagesCornersRoundness,
|
||||||
|
imagesAlignment = imagesAlignment,
|
||||||
|
imagesWidth = imagesWidth,
|
||||||
|
imagesColorEffects = imagesColorEffects,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
fontThickness = fontThickness,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
chapterTitleAlignment = chapterTitleAlignment,
|
||||||
|
textAlignment = textAlignment,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
fontSize = fontSize,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
paragraphIndentation = paragraphIndentation,
|
||||||
|
doubleClickTranslation = doubleClickTranslation,
|
||||||
|
isLoading = isLoading,
|
||||||
|
showMenu = showMenu,
|
||||||
|
menuVisibility = menuVisibility,
|
||||||
|
openShareApp = openShareApp,
|
||||||
|
openWebBrowser = openWebBrowser,
|
||||||
|
openTranslator = openTranslator,
|
||||||
|
openDictionary = openDictionary,
|
||||||
|
onPageChanged = onPageChanged
|
||||||
|
)
|
||||||
|
} else {
|
||||||
LazyColumnWithScrollbar(
|
LazyColumnWithScrollbar(
|
||||||
state = listState,
|
state = listState,
|
||||||
enableScrollbar = false,
|
enableScrollbar = false,
|
||||||
|
|
@ -242,3 +303,4 @@ fun ReaderLayout(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.ui.reader
|
package org.dueattendant149.bookshelf.ui.reader
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
@ -23,7 +22,7 @@ import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
|
||||||
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LazyItemScope.ReaderLayoutText(
|
fun ReaderLayoutText(
|
||||||
showMenu: Boolean,
|
showMenu: Boolean,
|
||||||
entry: ReaderText,
|
entry: ReaderText,
|
||||||
imagesCornersRoundness: Dp,
|
imagesCornersRoundness: Dp,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Spacer
|
||||||
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.LazyItemScope
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -26,7 +25,7 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.common.StyledText
|
import org.dueattendant149.bookshelf.ui.common.components.common.StyledText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LazyItemScope.ReaderLayoutTextChapter(
|
fun ReaderLayoutTextChapter(
|
||||||
chapter: Chapter,
|
chapter: Chapter,
|
||||||
chapterTitleAlignment: ReaderTextAlignment,
|
chapterTitleAlignment: ReaderTextAlignment,
|
||||||
fontColor: Color,
|
fontColor: Color,
|
||||||
|
|
@ -36,10 +35,6 @@ fun LazyItemScope.ReaderLayoutTextChapter(
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.animateItem(
|
|
||||||
fadeInSpec = null,
|
|
||||||
fadeOutSpec = null
|
|
||||||
)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(22.dp))
|
Spacer(modifier = Modifier.height(22.dp))
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -22,7 +21,7 @@ import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||||
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LazyItemScope.ReaderLayoutTextImage(
|
fun ReaderLayoutTextImage(
|
||||||
entry: ReaderText.Image,
|
entry: ReaderText.Image,
|
||||||
sidePadding: Dp,
|
sidePadding: Dp,
|
||||||
imagesCornersRoundness: Dp,
|
imagesCornersRoundness: Dp,
|
||||||
|
|
@ -32,10 +31,6 @@ fun LazyItemScope.ReaderLayoutTextImage(
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.animateItem(
|
|
||||||
fadeInSpec = null,
|
|
||||||
fadeOutSpec = null
|
|
||||||
)
|
|
||||||
.padding(horizontal = sidePadding)
|
.padding(horizontal = sidePadding)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
contentAlignment = imagesAlignment.alignment
|
contentAlignment = imagesAlignment.alignment
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,12 @@ import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.LineBreak
|
|
||||||
import androidx.compose.ui.text.style.TextIndent
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Text
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText.Text
|
||||||
|
|
@ -29,9 +25,10 @@ import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignme
|
||||||
import org.dueattendant149.bookshelf.ui.common.components.common.StyledText
|
import org.dueattendant149.bookshelf.ui.common.components.common.StyledText
|
||||||
import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
|
import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
|
||||||
import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
|
import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
|
||||||
|
import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LazyItemScope.ReaderLayoutTextParagraph(
|
fun ReaderLayoutTextParagraph(
|
||||||
paragraph: Text,
|
paragraph: Text,
|
||||||
showMenu: Boolean,
|
showMenu: Boolean,
|
||||||
fontFamily: FontWithName,
|
fontFamily: FontWithName,
|
||||||
|
|
@ -52,9 +49,20 @@ fun LazyItemScope.ReaderLayoutTextParagraph(
|
||||||
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
|
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
|
||||||
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
|
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val style = readerParagraphTextStyle(
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
fontThickness = fontThickness,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
textAlignment = textAlignment,
|
||||||
|
fontSize = fontSize,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
paragraphIndentation = paragraphIndentation,
|
||||||
|
color = fontColor,
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = sidePadding),
|
.padding(horizontal = sidePadding),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
|
|
@ -84,18 +92,7 @@ fun LazyItemScope.ReaderLayoutTextParagraph(
|
||||||
)
|
)
|
||||||
} else Modifier
|
} else Modifier
|
||||||
),
|
),
|
||||||
style = TextStyle(
|
style = style,
|
||||||
fontFamily = fontFamily.font,
|
|
||||||
fontWeight = fontThickness.thickness,
|
|
||||||
textAlign = textAlignment.textAlignment,
|
|
||||||
textIndent = TextIndent(firstLine = paragraphIndentation),
|
|
||||||
fontStyle = fontStyle,
|
|
||||||
letterSpacing = letterSpacing,
|
|
||||||
fontSize = fontSize,
|
|
||||||
lineHeight = lineHeight,
|
|
||||||
color = fontColor,
|
|
||||||
lineBreak = LineBreak.Paragraph
|
|
||||||
),
|
|
||||||
highlightText = highlightedReading,
|
highlightText = highlightedReading,
|
||||||
highlightThickness = highlightedReadingThickness
|
highlightThickness = highlightedReadingThickness
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@
|
||||||
package org.dueattendant149.bookshelf.ui.reader
|
package org.dueattendant149.bookshelf.ui.reader
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyItemScope
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -18,17 +17,13 @@ import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LazyItemScope.ReaderLayoutTextSeparator(
|
fun ReaderLayoutTextSeparator(
|
||||||
sidePadding: Dp,
|
sidePadding: Dp,
|
||||||
fontColor: Color
|
fontColor: Color
|
||||||
) {
|
) {
|
||||||
HorizontalDivider(
|
HorizontalDivider(
|
||||||
thickness = 3.dp,
|
thickness = 3.dp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.animateItem(
|
|
||||||
fadeInSpec = null,
|
|
||||||
fadeOutSpec = null
|
|
||||||
)
|
|
||||||
.padding(horizontal = sidePadding)
|
.padding(horizontal = sidePadding)
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
color = fontColor.copy(0.3f)
|
color = fontColor.copy(0.3f)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.reader
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.PageSize.Fill
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderPage
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.reader.ReaderText
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.ReaderEvent
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.common.SelectionContainer
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.noRippleClickable
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.showToast
|
||||||
|
import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
|
||||||
|
import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReaderPaginationLayout(
|
||||||
|
pages: List<ReaderPage>,
|
||||||
|
currentPage: Int,
|
||||||
|
contentPadding: PaddingValues,
|
||||||
|
verticalPadding: Dp,
|
||||||
|
highlightedReading: Boolean,
|
||||||
|
highlightedReadingThickness: FontWeight,
|
||||||
|
progress: String,
|
||||||
|
progressBar: Boolean,
|
||||||
|
progressBarPadding: Dp,
|
||||||
|
progressBarAlignment: HorizontalAlignment,
|
||||||
|
progressBarFontSize: TextUnit,
|
||||||
|
paragraphHeight: Dp,
|
||||||
|
sidePadding: Dp,
|
||||||
|
bottomBarPadding: Dp,
|
||||||
|
backgroundColor: Color,
|
||||||
|
fontColor: Color,
|
||||||
|
images: Boolean,
|
||||||
|
imagesCaptions: Boolean,
|
||||||
|
imagesCornersRoundness: Dp,
|
||||||
|
imagesAlignment: HorizontalAlignment,
|
||||||
|
imagesWidth: Float,
|
||||||
|
imagesColorEffects: ColorFilter?,
|
||||||
|
fontFamily: FontWithName,
|
||||||
|
lineHeight: TextUnit,
|
||||||
|
fontThickness: ReaderFontThickness,
|
||||||
|
fontStyle: FontStyle,
|
||||||
|
chapterTitleAlignment: ReaderTextAlignment,
|
||||||
|
textAlignment: ReaderTextAlignment,
|
||||||
|
horizontalAlignment: Alignment.Horizontal,
|
||||||
|
fontSize: TextUnit,
|
||||||
|
letterSpacing: TextUnit,
|
||||||
|
paragraphIndentation: TextUnit,
|
||||||
|
doubleClickTranslation: Boolean,
|
||||||
|
isLoading: Boolean,
|
||||||
|
showMenu: Boolean,
|
||||||
|
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit,
|
||||||
|
openShareApp: (ReaderEvent.OnOpenShareApp) -> Unit,
|
||||||
|
openWebBrowser: (ReaderEvent.OnOpenWebBrowser) -> Unit,
|
||||||
|
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
|
||||||
|
openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit,
|
||||||
|
onPageChanged: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
if (pages.isEmpty()) return
|
||||||
|
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = currentPage.coerceIn(0, pages.lastIndex),
|
||||||
|
pageCount = { pages.size }
|
||||||
|
)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
|
||||||
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
|
onPageChanged(pagerState.currentPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(currentPage) {
|
||||||
|
if (currentPage != pagerState.currentPage && currentPage in pages.indices) {
|
||||||
|
pagerState.animateScrollToPage(currentPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionContainer(
|
||||||
|
onCopyRequested = {
|
||||||
|
activity.getString(org.dueattendant149.bookshelf.R.string.copied)
|
||||||
|
.showToast(context = activity, longToast = false)
|
||||||
|
},
|
||||||
|
onShareRequested = { textToShare ->
|
||||||
|
openShareApp(ReaderEvent.OnOpenShareApp(textToShare = textToShare))
|
||||||
|
},
|
||||||
|
onWebSearchRequested = { textToSearch ->
|
||||||
|
openWebBrowser(ReaderEvent.OnOpenWebBrowser(textToSearch = textToSearch))
|
||||||
|
},
|
||||||
|
onTranslateRequested = { textToTranslate ->
|
||||||
|
openTranslator(
|
||||||
|
ReaderEvent.OnOpenTranslator(
|
||||||
|
textToTranslate = textToTranslate,
|
||||||
|
translateWholeParagraph = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDictionaryRequested = { textToDefine ->
|
||||||
|
openDictionary(ReaderEvent.OnOpenDictionary(textToDefine = textToDefine))
|
||||||
|
}
|
||||||
|
) { toolbarHidden ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(backgroundColor)
|
||||||
|
.then(
|
||||||
|
if (!isLoading && toolbarHidden) {
|
||||||
|
Modifier.noRippleClickable(
|
||||||
|
onClick = {
|
||||||
|
menuVisibility(
|
||||||
|
ReaderEvent.OnMenuVisibility(
|
||||||
|
show = !showMenu,
|
||||||
|
saveCheckpoint = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
.padding(contentPadding)
|
||||||
|
.padding(vertical = verticalPadding)
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
pageSize = Fill,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
) { pageIndex ->
|
||||||
|
ReaderPageContent(
|
||||||
|
page = pages[pageIndex],
|
||||||
|
verticalPadding = paragraphHeight,
|
||||||
|
sidePadding = sidePadding,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
fontColor = fontColor,
|
||||||
|
images = images,
|
||||||
|
imagesCornersRoundness = imagesCornersRoundness,
|
||||||
|
imagesAlignment = imagesAlignment,
|
||||||
|
imagesWidth = imagesWidth,
|
||||||
|
imagesColorEffects = imagesColorEffects,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
fontThickness = fontThickness,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
chapterTitleAlignment = chapterTitleAlignment,
|
||||||
|
textAlignment = textAlignment,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
fontSize = fontSize,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
paragraphIndentation = paragraphIndentation,
|
||||||
|
highlightedReading = highlightedReading,
|
||||||
|
highlightedReadingThickness = highlightedReadingThickness,
|
||||||
|
toolbarHidden = toolbarHidden,
|
||||||
|
doubleClickTranslation = doubleClickTranslation,
|
||||||
|
showMenu = showMenu,
|
||||||
|
openTranslator = openTranslator,
|
||||||
|
menuVisibility = menuVisibility
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showMenu && progressBar) {
|
||||||
|
ReaderProgressBar(
|
||||||
|
progress = progress,
|
||||||
|
progressBarPadding = progressBarPadding,
|
||||||
|
progressBarAlignment = progressBarAlignment,
|
||||||
|
progressBarFontSize = progressBarFontSize,
|
||||||
|
fontColor = fontColor,
|
||||||
|
sidePadding = sidePadding
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReaderPageContent(
|
||||||
|
page: ReaderPage,
|
||||||
|
verticalPadding: Dp,
|
||||||
|
sidePadding: Dp,
|
||||||
|
backgroundColor: Color,
|
||||||
|
fontColor: Color,
|
||||||
|
images: Boolean,
|
||||||
|
imagesCornersRoundness: Dp,
|
||||||
|
imagesAlignment: HorizontalAlignment,
|
||||||
|
imagesWidth: Float,
|
||||||
|
imagesColorEffects: ColorFilter?,
|
||||||
|
fontFamily: FontWithName,
|
||||||
|
lineHeight: TextUnit,
|
||||||
|
fontThickness: ReaderFontThickness,
|
||||||
|
fontStyle: FontStyle,
|
||||||
|
chapterTitleAlignment: ReaderTextAlignment,
|
||||||
|
textAlignment: ReaderTextAlignment,
|
||||||
|
horizontalAlignment: Alignment.Horizontal,
|
||||||
|
fontSize: TextUnit,
|
||||||
|
letterSpacing: TextUnit,
|
||||||
|
paragraphIndentation: TextUnit,
|
||||||
|
highlightedReading: Boolean,
|
||||||
|
highlightedReadingThickness: FontWeight,
|
||||||
|
toolbarHidden: Boolean,
|
||||||
|
doubleClickTranslation: Boolean,
|
||||||
|
showMenu: Boolean,
|
||||||
|
openTranslator: (ReaderEvent.OnOpenTranslator) -> Unit,
|
||||||
|
menuVisibility: (ReaderEvent.OnMenuVisibility) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(vertical = verticalPadding, horizontal = sidePadding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(verticalPadding, Alignment.Top),
|
||||||
|
horizontalAlignment = horizontalAlignment
|
||||||
|
) {
|
||||||
|
page.items.forEachIndexed { index, entry ->
|
||||||
|
val previousEntry = page.items.getOrNull(index - 1)
|
||||||
|
when {
|
||||||
|
!images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@forEachIndexed
|
||||||
|
!images && previousEntry is ReaderText.Image -> return@forEachIndexed
|
||||||
|
else -> {
|
||||||
|
ReaderLayoutText(
|
||||||
|
showMenu = showMenu,
|
||||||
|
entry = entry,
|
||||||
|
imagesCornersRoundness = imagesCornersRoundness,
|
||||||
|
imagesAlignment = imagesAlignment,
|
||||||
|
imagesWidth = imagesWidth,
|
||||||
|
imagesColorEffects = imagesColorEffects,
|
||||||
|
fontFamily = fontFamily,
|
||||||
|
fontColor = fontColor,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
fontThickness = fontThickness,
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
chapterTitleAlignment = chapterTitleAlignment,
|
||||||
|
textAlignment = textAlignment,
|
||||||
|
horizontalAlignment = horizontalAlignment,
|
||||||
|
fontSize = fontSize,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
sidePadding = 0.dp,
|
||||||
|
paragraphIndentation = paragraphIndentation,
|
||||||
|
doubleClickTranslation = doubleClickTranslation,
|
||||||
|
highlightedReading = highlightedReading,
|
||||||
|
highlightedReadingThickness = highlightedReadingThickness,
|
||||||
|
toolbarHidden = toolbarHidden,
|
||||||
|
openTranslator = openTranslator,
|
||||||
|
menuVisibility = menuVisibility
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,8 +62,13 @@ fun ReaderScaffold(
|
||||||
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,
|
||||||
|
bottomBarPagination: Boolean = readerPagination,
|
||||||
horizontalGesture: ReaderHorizontalGesture,
|
horizontalGesture: ReaderHorizontalGesture,
|
||||||
horizontalGestureScroll: Float,
|
horizontalGestureScroll: Float,
|
||||||
horizontalGestureSensitivity: Dp,
|
horizontalGestureSensitivity: Dp,
|
||||||
|
|
@ -112,6 +117,7 @@ fun ReaderScaffold(
|
||||||
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
|
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
|
||||||
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
||||||
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
||||||
|
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
|
||||||
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
|
@ -137,7 +143,8 @@ fun ReaderScaffold(
|
||||||
showSettingsBottomSheet = showSettingsBottomSheet,
|
showSettingsBottomSheet = showSettingsBottomSheet,
|
||||||
showChaptersDrawer = showChaptersDrawer,
|
showChaptersDrawer = showChaptersDrawer,
|
||||||
navigateBack = navigateBack,
|
navigateBack = navigateBack,
|
||||||
navigateToBookInfo = navigateToBookInfo
|
navigateToBookInfo = navigateToBookInfo,
|
||||||
|
navigateToRsvp = navigateToRsvp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -156,6 +163,10 @@ fun ReaderScaffold(
|
||||||
lockMenu = lockMenu,
|
lockMenu = lockMenu,
|
||||||
checkpoints = checkpoints,
|
checkpoints = checkpoints,
|
||||||
bottomBarPadding = bottomBarPadding,
|
bottomBarPadding = bottomBarPadding,
|
||||||
|
readerPagination = readerPagination,
|
||||||
|
pages = pages,
|
||||||
|
currentPage = currentPage,
|
||||||
|
onChangePage = onPageChanged,
|
||||||
restoreCheckpoint = restoreCheckpoint,
|
restoreCheckpoint = restoreCheckpoint,
|
||||||
scroll = scroll,
|
scroll = scroll,
|
||||||
changeProgress = changeProgress
|
changeProgress = changeProgress
|
||||||
|
|
@ -165,6 +176,10 @@ fun ReaderScaffold(
|
||||||
) {
|
) {
|
||||||
ReaderLayout(
|
ReaderLayout(
|
||||||
text = text,
|
text = text,
|
||||||
|
pages = pages,
|
||||||
|
currentPage = currentPage,
|
||||||
|
readerPagination = readerPagination,
|
||||||
|
onPageChanged = onPageChanged,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalPadding = verticalPadding,
|
verticalPadding = verticalPadding,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.reader
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.PlatformTextStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.LineBreak
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextIndent
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderFontThickness
|
||||||
|
import org.dueattendant149.bookshelf.presentation.reader.model.ReaderTextAlignment
|
||||||
|
import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a [TextStyle] that is used both for measuring text during pagination
|
||||||
|
* and for rendering paragraphs in the reader. Keeping the two in sync is
|
||||||
|
* essential; any difference will cause page breaks to drift from the actual
|
||||||
|
* rendered layout.
|
||||||
|
*/
|
||||||
|
fun readerParagraphTextStyle(
|
||||||
|
fontFamily: FontWithName,
|
||||||
|
fontThickness: ReaderFontThickness,
|
||||||
|
fontStyle: FontStyle,
|
||||||
|
textAlignment: ReaderTextAlignment,
|
||||||
|
fontSize: TextUnit,
|
||||||
|
lineHeight: TextUnit,
|
||||||
|
letterSpacing: TextUnit,
|
||||||
|
paragraphIndentation: TextUnit,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
lineBreak: LineBreak = LineBreak.Paragraph,
|
||||||
|
includeFontPadding: Boolean = false,
|
||||||
|
): TextStyle {
|
||||||
|
return TextStyle(
|
||||||
|
fontFamily = fontFamily.font,
|
||||||
|
fontWeight = fontThickness.thickness,
|
||||||
|
textAlign = textAlignment.textAlignment,
|
||||||
|
textIndent = TextIndent(firstLine = paragraphIndentation),
|
||||||
|
fontStyle = fontStyle,
|
||||||
|
letterSpacing = letterSpacing,
|
||||||
|
fontSize = fontSize,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
color = color,
|
||||||
|
lineBreak = lineBreak,
|
||||||
|
platformStyle = PlatformTextStyle(includeFontPadding = includeFontPadding),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a [TextStyle] for measuring chapter titles. It intentionally mirrors
|
||||||
|
* the rendering style used by [ReaderLayoutTextChapter] as closely as possible
|
||||||
|
* without accessing MaterialTheme at the call site.
|
||||||
|
*/
|
||||||
|
fun readerChapterTextStyle(
|
||||||
|
nested: Boolean,
|
||||||
|
textAlignment: ReaderTextAlignment,
|
||||||
|
color: Color = Color.Unspecified,
|
||||||
|
): TextStyle {
|
||||||
|
// Mirror Material3 headlineMedium / headlineSmall sizing. These are the
|
||||||
|
// default Material3 values; they may differ slightly from a customized
|
||||||
|
// theme, but they are accurate enough for pagination.
|
||||||
|
val fontSize = if (nested) 24.sp else 28.sp
|
||||||
|
val lineHeight = if (nested) 32.sp else 36.sp
|
||||||
|
return TextStyle(
|
||||||
|
fontSize = fontSize,
|
||||||
|
lineHeight = lineHeight,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
textAlign = textAlignment.textAlignment,
|
||||||
|
color = color,
|
||||||
|
platformStyle = PlatformTextStyle(includeFontPadding = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.rounded.Menu
|
import androidx.compose.material.icons.rounded.Menu
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -53,6 +54,7 @@ fun ReaderTopBar(
|
||||||
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
|
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
|
||||||
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
|
||||||
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
|
||||||
|
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
|
||||||
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
|
||||||
) {
|
) {
|
||||||
val activity = LocalActivity.current
|
val activity = LocalActivity.current
|
||||||
|
|
@ -138,6 +140,15 @@ fun ReaderTopBar(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
icon = Icons.Default.Bolt,
|
||||||
|
contentDescription = R.string.rsvp_open_from_reader_desc,
|
||||||
|
disableOnClick = false,
|
||||||
|
enabled = !lockMenu
|
||||||
|
) {
|
||||||
|
navigateToRsvp(ReaderEvent.OnNavigateToRsvp(book.id))
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon = Icons.Default.Settings,
|
icon = Icons.Default.Settings,
|
||||||
contentDescription = R.string.open_reader_settings_content_desc,
|
contentDescription = R.string.open_reader_settings_content_desc,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package org.dueattendant149.bookshelf.ui.reader
|
||||||
|
|
||||||
|
import android.view.ActionMode
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import android.view.Window
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.view.SearchEvent
|
||||||
|
import android.view.accessibility.AccessibilityEvent
|
||||||
|
|
||||||
|
class VolumeKeyWindowCallback(
|
||||||
|
private val delegate: Window.Callback?,
|
||||||
|
private val onKeyEvent: (KeyEvent) -> Boolean,
|
||||||
|
) : Window.Callback {
|
||||||
|
|
||||||
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||||
|
if (onKeyEvent(event)) return true
|
||||||
|
return delegate?.dispatchKeyEvent(event) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatchKeyShortcutEvent(event: KeyEvent): Boolean = delegate?.dispatchKeyShortcutEvent(event) ?: false
|
||||||
|
override fun dispatchTouchEvent(event: MotionEvent): Boolean = delegate?.dispatchTouchEvent(event) ?: false
|
||||||
|
override fun dispatchTrackballEvent(event: MotionEvent): Boolean = delegate?.dispatchTrackballEvent(event) ?: false
|
||||||
|
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean = delegate?.dispatchGenericMotionEvent(event) ?: false
|
||||||
|
override fun dispatchPopulateAccessibilityEvent(event: AccessibilityEvent): Boolean = delegate?.dispatchPopulateAccessibilityEvent(event) ?: false
|
||||||
|
override fun onCreatePanelView(featureId: Int): View? = delegate?.onCreatePanelView(featureId)
|
||||||
|
override fun onCreatePanelMenu(featureId: Int, menu: Menu): Boolean = delegate?.onCreatePanelMenu(featureId, menu) ?: false
|
||||||
|
override fun onPreparePanel(featureId: Int, view: View?, menu: Menu): Boolean = delegate?.onPreparePanel(featureId, view, menu) ?: false
|
||||||
|
override fun onMenuOpened(featureId: Int, menu: Menu): Boolean = delegate?.onMenuOpened(featureId, menu) ?: false
|
||||||
|
override fun onMenuItemSelected(featureId: Int, item: MenuItem): Boolean = delegate?.onMenuItemSelected(featureId, item) ?: false
|
||||||
|
override fun onWindowAttributesChanged(attrs: WindowManager.LayoutParams) { delegate?.onWindowAttributesChanged(attrs) }
|
||||||
|
override fun onContentChanged() { delegate?.onContentChanged() }
|
||||||
|
override fun onWindowFocusChanged(hasFocus: Boolean) { delegate?.onWindowFocusChanged(hasFocus) }
|
||||||
|
override fun onWindowStartingActionMode(callback: ActionMode.Callback?): ActionMode? = delegate?.onWindowStartingActionMode(callback)
|
||||||
|
override fun onWindowStartingActionMode(callback: ActionMode.Callback?, type: Int): ActionMode? = delegate?.onWindowStartingActionMode(callback, type)
|
||||||
|
override fun onActionModeStarted(mode: ActionMode) { delegate?.onActionModeStarted(mode) }
|
||||||
|
override fun onActionModeFinished(mode: ActionMode) { delegate?.onActionModeFinished(mode) }
|
||||||
|
override fun onPanelClosed(featureId: Int, menu: Menu) { delegate?.onPanelClosed(featureId, menu) }
|
||||||
|
override fun onSearchRequested(): Boolean = delegate?.onSearchRequested() ?: false
|
||||||
|
override fun onSearchRequested(event: SearchEvent?): Boolean = delegate?.onSearchRequested(event) ?: false
|
||||||
|
override fun onAttachedToWindow() { delegate?.onAttachedToWindow() }
|
||||||
|
override fun onDetachedFromWindow() { delegate?.onDetachedFromWindow() }
|
||||||
|
override fun onPointerCaptureChanged(hasCapture: Boolean) { delegate?.onPointerCaptureChanged(hasCapture) }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.rsvp
|
||||||
|
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.FastForward
|
||||||
|
import androidx.compose.material.icons.filled.FastRewind
|
||||||
|
import androidx.compose.material.icons.filled.Pause
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.TextUnit
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.rsvp.RsvpToken
|
||||||
|
import org.dueattendant149.bookshelf.presentation.rsvp.RsvpEvent
|
||||||
|
import org.dueattendant149.bookshelf.presentation.rsvp.RsvpModel
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalActivity
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val LocalRsvpContainerWidth = staticCompositionLocalOf<Dp> { 360.dp }
|
||||||
|
|
||||||
|
private const val RSVP_MIN_FONT_SP = 16
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun computeRsvpFontSize(
|
||||||
|
word: String,
|
||||||
|
maxFontSize: TextUnit,
|
||||||
|
containerWidth: Dp,
|
||||||
|
paddingDp: Dp,
|
||||||
|
): TextUnit {
|
||||||
|
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
val availablePx = with(density) { (containerWidth - paddingDp * 2).toPx() }.coerceAtLeast(1f)
|
||||||
|
|
||||||
|
// Binary search the largest font size that still fits.
|
||||||
|
var low = RSVP_MIN_FONT_SP.toFloat()
|
||||||
|
var high = maxFontSize.value
|
||||||
|
var best = low
|
||||||
|
val textStyle = TextStyle(
|
||||||
|
fontSize = 1.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
)
|
||||||
|
repeat(10) {
|
||||||
|
val mid = (low + high) / 2f
|
||||||
|
val width = textMeasurer.measure(
|
||||||
|
text = word,
|
||||||
|
style = textStyle.copy(fontSize = mid.sp),
|
||||||
|
constraints = Constraints(),
|
||||||
|
).size.width.toFloat()
|
||||||
|
if (width <= availablePx) {
|
||||||
|
best = mid
|
||||||
|
low = mid
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (best.sp.value < maxFontSize.value) best.sp else maxFontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpContent(model: RsvpModel) {
|
||||||
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
|
val engineState by model.engineState.collectAsStateWithLifecycle()
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
onDispose {
|
||||||
|
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = true) {
|
||||||
|
model.onEvent(RsvpEvent.OnClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = engineState.currentToken
|
||||||
|
val tokenCount = engineState.tokens.size.coerceAtLeast(1)
|
||||||
|
val progress = engineState.progress
|
||||||
|
val wpm = engineState.wpm.coerceAtLeast(1)
|
||||||
|
val remainingMinutes = remember(engineState.currentIndex, tokenCount, wpm) {
|
||||||
|
(((tokenCount - engineState.currentIndex).coerceAtLeast(1)).toFloat() / wpm.toFloat())
|
||||||
|
.roundToInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
) { padding ->
|
||||||
|
CompositionLocalProvider(LocalRsvpContainerWidth provides screenWidth) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectTapGestures(
|
||||||
|
onTap = {
|
||||||
|
model.onEvent(
|
||||||
|
if (engineState.isPlaying) RsvpEvent.OnPlayPause
|
||||||
|
else RsvpEvent.OnToggleControls
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onDoubleTap = { model.onEvent(RsvpEvent.OnPlayPause) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rsvp_loading),
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorMessage != null -> {
|
||||||
|
Text(
|
||||||
|
text = state.errorMessage ?: stringResource(R.string.rsvp_no_text),
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
token != null -> {
|
||||||
|
WordStage(
|
||||||
|
token = token,
|
||||||
|
fontSizeSp = settings.rsvpFontSize.value.coerceIn(20, 96),
|
||||||
|
showFocusGuide = settings.rsvpShowFocusGuide.value,
|
||||||
|
progress = progress,
|
||||||
|
currentIndex = engineState.currentIndex,
|
||||||
|
tokenCount = tokenCount,
|
||||||
|
remainingMinutes = remainingMinutes,
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = state.showControls || !engineState.isPlaying,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter)
|
||||||
|
) {
|
||||||
|
ControlsBar(
|
||||||
|
isPlaying = engineState.isPlaying,
|
||||||
|
wpm = wpm,
|
||||||
|
onPlayPause = { model.onEvent(RsvpEvent.OnPlayPause) },
|
||||||
|
onSkipBack = { model.onEvent(RsvpEvent.OnSkip(-10)) },
|
||||||
|
onSkipForward = { model.onEvent(RsvpEvent.OnSkip(10)) },
|
||||||
|
onWpmChange = { model.onEvent(RsvpEvent.OnWpmChange(it)) },
|
||||||
|
onClose = { model.onEvent(RsvpEvent.OnClose) },
|
||||||
|
onSeek = { model.onEvent(RsvpEvent.OnSeekToProgress(it)) },
|
||||||
|
progress = progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WordStage(
|
||||||
|
token: RsvpToken,
|
||||||
|
fontSizeSp: Int,
|
||||||
|
showFocusGuide: Boolean,
|
||||||
|
progress: Float,
|
||||||
|
currentIndex: Int,
|
||||||
|
tokenCount: Int,
|
||||||
|
remainingMinutes: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pivotColor = MaterialTheme.colorScheme.primary
|
||||||
|
val bodyColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Top progress bar
|
||||||
|
Slider(
|
||||||
|
value = progress,
|
||||||
|
onValueChange = {},
|
||||||
|
enabled = false,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
|
// Focus guide top
|
||||||
|
if (showFocusGuide) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rsvp_focus_guide_top),
|
||||||
|
color = pivotColor.copy(alpha = 0.5f),
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word with pivot highlight, auto-shrunk and centered
|
||||||
|
WordText(
|
||||||
|
token = token,
|
||||||
|
fontSizeSp = fontSizeSp,
|
||||||
|
containerWidth = LocalRsvpContainerWidth.current,
|
||||||
|
bodyColor = bodyColor,
|
||||||
|
pivotColor = pivotColor
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
// Focus guide bottom
|
||||||
|
if (showFocusGuide) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rsvp_focus_guide_bottom),
|
||||||
|
color = pivotColor.copy(alpha = 0.5f),
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rsvp_progress_label, currentIndex + 1, tokenCount),
|
||||||
|
color = bodyColor.copy(alpha = 0.6f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rsvp_remaining_label, remainingMinutes.coerceAtLeast(0)),
|
||||||
|
color = bodyColor.copy(alpha = 0.4f),
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlsBar(
|
||||||
|
isPlaying: Boolean,
|
||||||
|
wpm: Int,
|
||||||
|
onPlayPause: () -> Unit,
|
||||||
|
onSkipBack: () -> Unit,
|
||||||
|
onSkipForward: () -> Unit,
|
||||||
|
onWpmChange: (Int) -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
onSeek: (Float) -> Unit,
|
||||||
|
progress: Float,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
// Seek slider (allows tapping to seek)
|
||||||
|
Slider(
|
||||||
|
value = progress,
|
||||||
|
onValueChange = onSeek,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.rsvp_wpm_label, wpm),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = wpm.toFloat(),
|
||||||
|
onValueChange = { onWpmChange(it.roundToInt()) },
|
||||||
|
valueRange = 100f..900f,
|
||||||
|
steps = 15,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(R.string.rsvp_back)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onSkipBack) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FastRewind,
|
||||||
|
contentDescription = stringResource(R.string.rsvp_skip_back_10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onPlayPause, modifier = Modifier.width(72.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
if (isPlaying) R.string.rsvp_pause else R.string.rsvp_play
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onSkipForward) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FastForward,
|
||||||
|
contentDescription = stringResource(R.string.rsvp_skip_forward_10)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(48.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WordText(
|
||||||
|
token: RsvpToken,
|
||||||
|
fontSizeSp: Int,
|
||||||
|
containerWidth: Dp,
|
||||||
|
bodyColor: Color,
|
||||||
|
pivotColor: Color,
|
||||||
|
) {
|
||||||
|
val autoFontSize = computeRsvpFontSize(
|
||||||
|
word = token.word,
|
||||||
|
maxFontSize = fontSizeSp.sp,
|
||||||
|
containerWidth = containerWidth,
|
||||||
|
paddingDp = 32.dp,
|
||||||
|
)
|
||||||
|
val style = SpanStyle(
|
||||||
|
fontSize = autoFontSize,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
)
|
||||||
|
val annotatedWord = buildAnnotatedString {
|
||||||
|
withStyle(style.copy(color = bodyColor.copy(alpha = 0.55f), fontWeight = FontWeight.Normal)) {
|
||||||
|
append(token.prefix)
|
||||||
|
}
|
||||||
|
withStyle(style.copy(color = pivotColor, fontWeight = FontWeight.Bold)) {
|
||||||
|
append(token.pivot)
|
||||||
|
}
|
||||||
|
withStyle(style.copy(color = bodyColor.copy(alpha = 0.55f), fontWeight = FontWeight.Normal)) {
|
||||||
|
append(token.suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = annotatedWord,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(((fontSizeSp * 1.6f).toInt()).dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.search
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResultItemResponse
|
||||||
|
import org.dueattendant149.bookshelf.presentation.search.SearchModel
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.common.SearchTextField
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SearchContent(model: SearchModel) {
|
||||||
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var selectedUploadType by remember { mutableStateOf("ebook") }
|
||||||
|
var uploadMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val filePicker = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.GetContent()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
|
val file = uriToFile(context, uri) ?: return@rememberLauncherForActivityResult
|
||||||
|
val uploadType = inferUploadType(file.name) ?: selectedUploadType
|
||||||
|
model.uploadBook(file, uploadType)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.downloadSuccess) {
|
||||||
|
state.downloadSuccess?.let { title ->
|
||||||
|
snackbarHostState.showSnackbar("Download started: $title")
|
||||||
|
model.clearDownloadSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.uploadSuccess) {
|
||||||
|
state.uploadSuccess?.let { message ->
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
model.clearUploadSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = WindowInsets(0),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.search_screen)) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { padding ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
SearchTextField(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
initialQuery = state.query,
|
||||||
|
onQueryChange = model::setQuery,
|
||||||
|
onSearch = model::search
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { filePicker.launch("*/*") },
|
||||||
|
enabled = !state.isUploading,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
if (state.isUploading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text("Загрузить в библиотеку")
|
||||||
|
}
|
||||||
|
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = uploadMenuExpanded,
|
||||||
|
onExpandedChange = { uploadMenuExpanded = it },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = if (selectedUploadType == "audiobook") "Аудиокнига" else "Книга (EPUB/FB2/PDF)",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("Тип загрузки") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = uploadMenuExpanded) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable)
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = uploadMenuExpanded,
|
||||||
|
onDismissRequest = { uploadMenuExpanded = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Книга (EPUB/FB2/PDF)") },
|
||||||
|
onClick = {
|
||||||
|
selectedUploadType = "ebook"
|
||||||
|
uploadMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Аудиокнига") },
|
||||||
|
onClick = {
|
||||||
|
selectedUploadType = "audiobook"
|
||||||
|
uploadMenuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.isLoading) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 32.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
|
items = state.results,
|
||||||
|
key = { index, item -> "${item.guid}_$index" }
|
||||||
|
) { _, item ->
|
||||||
|
val dlProgress = state.downloadProgresses[item.title]
|
||||||
|
SearchResultCard(
|
||||||
|
item = item,
|
||||||
|
isDownloading = state.downloadingTitle == item.title,
|
||||||
|
downloadProgress = dlProgress,
|
||||||
|
onDownload = { model.startDownload(item) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.isLoading && state.results.isEmpty() && state.query.isNotBlank() && state.error == null) {
|
||||||
|
EmptyPlaceholder(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
message = stringResource(R.string.search_field_empty),
|
||||||
|
icon = rememberVectorPainter(Icons.Default.Search),
|
||||||
|
actionTitle = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.error?.let { error ->
|
||||||
|
ErrorPlaceholder(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
errorMessage = error,
|
||||||
|
icon = rememberVectorPainter(Icons.Default.CloudOff),
|
||||||
|
actionTitle = stringResource(R.string.bookshelf_retry_action),
|
||||||
|
action = model::search
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferUploadType(fileName: String?): String? {
|
||||||
|
if (fileName == null) return null
|
||||||
|
val name = fileName.lowercase()
|
||||||
|
return when {
|
||||||
|
AUDIOBOOK_EXTENSIONS.any { name.endsWith(it) } -> "audiobook"
|
||||||
|
EBOOK_EXTENSIONS.any { name.endsWith(it) } -> "ebook"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val AUDIOBOOK_EXTENSIONS = listOf(".mp3", ".m4b", ".m4a", ".flac", ".ogg", ".aac", ".mp4")
|
||||||
|
private val EBOOK_EXTENSIONS = listOf(".epub", ".fb2", ".pdf", ".mobi")
|
||||||
|
|
||||||
|
private fun uriToFile(context: Context, uri: Uri): File? {
|
||||||
|
return runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||||
|
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||||
|
val extension = inferExtensionFromMime(mime)
|
||||||
|
?: inferExtensionFromUriPath(uri.lastPathSegment)
|
||||||
|
?: ""
|
||||||
|
File(context.cacheDir, "upload$extension").apply {
|
||||||
|
outputStream().use { input.copyTo(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferExtensionFromMime(mime: String): String? = when {
|
||||||
|
mime.contains("epub") -> ".epub"
|
||||||
|
mime.contains("pdf") -> ".pdf"
|
||||||
|
mime.contains("fb2") -> ".fb2"
|
||||||
|
mime.contains("mobi") -> ".mobi"
|
||||||
|
mime.contains("audio") -> if (mime.contains("mp4") || mime.contains("m4b")) ".m4b" else ".mp3"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inferExtensionFromUriPath(path: String?): String? {
|
||||||
|
val name = path ?: "upload"
|
||||||
|
return when {
|
||||||
|
name.endsWith(".epub", ignoreCase = true) -> ".epub"
|
||||||
|
name.endsWith(".fb2", ignoreCase = true) -> ".fb2"
|
||||||
|
name.endsWith(".pdf", ignoreCase = true) -> ".pdf"
|
||||||
|
name.endsWith(".mobi", ignoreCase = true) -> ".mobi"
|
||||||
|
name.endsWith(".m4b", ignoreCase = true) || name.endsWith(".m4a", ignoreCase = true) -> ".m4b"
|
||||||
|
name.endsWith(".mp3", ignoreCase = true) -> ".mp3"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchResultCard(
|
||||||
|
item: SearchResultItemResponse,
|
||||||
|
isDownloading: Boolean,
|
||||||
|
downloadProgress: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadStatusResponse?,
|
||||||
|
onDownload: () -> Unit,
|
||||||
|
) {
|
||||||
|
val progress = downloadProgress?.progress?.toFloat()?.coerceIn(0f, 1f) ?: 0f
|
||||||
|
val description = buildString {
|
||||||
|
append(item.source)
|
||||||
|
append(" • ")
|
||||||
|
append(item.format)
|
||||||
|
if (item.sizeHuman.isNotBlank()) {
|
||||||
|
append(" • ")
|
||||||
|
append(item.sizeHuman)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val book = org.dueattendant149.bookshelf.domain.model.library.Book(
|
||||||
|
title = item.title,
|
||||||
|
author = org.dueattendant149.bookshelf.core.ui.UIText.StringValue(item.author),
|
||||||
|
description = description,
|
||||||
|
filePath = "",
|
||||||
|
coverImage = null,
|
||||||
|
scrollIndex = 0,
|
||||||
|
scrollOffset = 0,
|
||||||
|
progress = progress,
|
||||||
|
lastOpened = null,
|
||||||
|
categories = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BookCard(
|
||||||
|
book = book,
|
||||||
|
progress = progress,
|
||||||
|
primaryButton = {
|
||||||
|
PrimaryActionButton(
|
||||||
|
label = if (isDownloading) "…" else stringResource(R.string.bookshelf_download_action),
|
||||||
|
enabled = !isDownloading,
|
||||||
|
onClick = onDownload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
|
||||||
fun SettingsContent(
|
fun SettingsContent(
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
isRoot: Boolean = false,
|
||||||
navigateToGeneralSettings: () -> Unit,
|
navigateToGeneralSettings: () -> Unit,
|
||||||
navigateToAppearanceSettings: () -> Unit,
|
navigateToAppearanceSettings: () -> Unit,
|
||||||
navigateToReaderSettings: () -> Unit,
|
navigateToReaderSettings: () -> Unit,
|
||||||
|
|
@ -28,6 +29,7 @@ fun SettingsContent(
|
||||||
SettingsScaffold(
|
SettingsScaffold(
|
||||||
listState = listState,
|
listState = listState,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
isRoot = isRoot,
|
||||||
navigateToGeneralSettings = navigateToGeneralSettings,
|
navigateToGeneralSettings = navigateToGeneralSettings,
|
||||||
navigateToAppearanceSettings = navigateToAppearanceSettings,
|
navigateToAppearanceSettings = navigateToAppearanceSettings,
|
||||||
navigateToReaderSettings = navigateToReaderSettings,
|
navigateToReaderSettings = navigateToReaderSettings,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
fun SettingsScaffold(
|
fun SettingsScaffold(
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
isRoot: Boolean = false,
|
||||||
navigateToGeneralSettings: () -> Unit,
|
navigateToGeneralSettings: () -> Unit,
|
||||||
navigateToAppearanceSettings: () -> Unit,
|
navigateToAppearanceSettings: () -> Unit,
|
||||||
navigateToReaderSettings: () -> Unit,
|
navigateToReaderSettings: () -> Unit,
|
||||||
|
|
@ -42,6 +43,7 @@ fun SettingsScaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
SettingsTopBar(
|
SettingsTopBar(
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
isRoot = isRoot,
|
||||||
navigateToStart = navigateToStart,
|
navigateToStart = navigateToStart,
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsTopBar(
|
fun SettingsTopBar(
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
isRoot: Boolean = false,
|
||||||
navigateToStart: () -> Unit,
|
navigateToStart: () -> Unit,
|
||||||
navigateBack: () -> Unit
|
navigateBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
@ -32,9 +33,11 @@ fun SettingsTopBar(
|
||||||
StyledText(stringResource(id = R.string.settings_screen))
|
StyledText(stringResource(id = R.string.settings_screen))
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
|
if (!isRoot) {
|
||||||
NavigatorBackIconButton(
|
NavigatorBackIconButton(
|
||||||
navigateBack = navigateBack
|
navigateBack = navigateBack
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.
|
||||||
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGesturePullAnimOption
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGesturePullAnimOption
|
||||||
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureScrollOption
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureScrollOption
|
||||||
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureSensitivityOption
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.HorizontalGestureSensitivityOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components.ReaderPaginationOption
|
||||||
|
|
||||||
fun LazyListScope.ReadingModeSubcategory(
|
fun LazyListScope.ReadingModeSubcategory(
|
||||||
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
|
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
|
||||||
|
|
@ -34,6 +35,10 @@ fun LazyListScope.ReadingModeSubcategory(
|
||||||
showTitle = showTitle,
|
showTitle = showTitle,
|
||||||
showDivider = showDivider
|
showDivider = showDivider
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
|
ReaderPaginationOption()
|
||||||
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
HorizontalGestureOption()
|
HorizontalGestureOption()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.settings.reader.reading_mode.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.settings.SwitchWithTitle
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReaderPaginationOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
val selected = settings.readerPagination.value
|
||||||
|
|
||||||
|
SwitchWithTitle(
|
||||||
|
title = stringResource(id = R.string.reader_pagination_option),
|
||||||
|
description = stringResource(id = R.string.reader_pagination_option_desc),
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
settings.readerPagination.update(!selected)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,12 @@ import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
|
||||||
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderOption
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderOption
|
||||||
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderPaddingOption
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderPaddingOption
|
||||||
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderThicknessOption
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.PerceptionExpanderThicknessOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpFocusGuideOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpFontSizeOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseChapterOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseLongWordsOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpPauseParagraphOption
|
||||||
|
import org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components.RsvpWpmOption
|
||||||
|
|
||||||
fun LazyListScope.ReadingSpeedSubcategory(
|
fun LazyListScope.ReadingSpeedSubcategory(
|
||||||
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
|
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
|
||||||
|
|
@ -82,5 +88,29 @@ fun LazyListScope.ReadingSpeedSubcategory(
|
||||||
item {
|
item {
|
||||||
HorizontalLimiterDimmingOption()
|
HorizontalLimiterDimmingOption()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RsvpWpmOption()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RsvpFontSizeOption()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RsvpPauseParagraphOption()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RsvpPauseChapterOption()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RsvpPauseLongWordsOption()
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
RsvpFocusGuideOption()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.settings.SliderWithTitle
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpFontSizeOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
|
||||||
|
SliderWithTitle(
|
||||||
|
value = settings.rsvpFontSize.value to "sp",
|
||||||
|
fromValue = 24,
|
||||||
|
toValue = 96,
|
||||||
|
title = stringResource(id = R.string.rsvp_font_size_option),
|
||||||
|
onValueChange = { settings.rsvpFontSize.update(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.settings.SwitchWithTitle
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpPauseParagraphOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
RsvpSwitchOption(
|
||||||
|
selected = settings.rsvpPauseOnParagraph.value,
|
||||||
|
titleRes = R.string.rsvp_pause_paragraph_option,
|
||||||
|
descRes = R.string.rsvp_pause_paragraph_option_desc,
|
||||||
|
onToggle = { settings.rsvpPauseOnParagraph.update(!settings.rsvpPauseOnParagraph.lastValue) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpPauseChapterOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
RsvpSwitchOption(
|
||||||
|
selected = settings.rsvpPauseOnChapter.value,
|
||||||
|
titleRes = R.string.rsvp_pause_chapter_option,
|
||||||
|
descRes = R.string.rsvp_pause_chapter_option_desc,
|
||||||
|
onToggle = { settings.rsvpPauseOnChapter.update(!settings.rsvpPauseOnChapter.lastValue) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpPauseLongWordsOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
RsvpSwitchOption(
|
||||||
|
selected = settings.rsvpPauseOnLongWords.value,
|
||||||
|
titleRes = R.string.rsvp_pause_long_words_option,
|
||||||
|
descRes = R.string.rsvp_pause_long_words_option_desc,
|
||||||
|
onToggle = { settings.rsvpPauseOnLongWords.update(!settings.rsvpPauseOnLongWords.lastValue) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpFocusGuideOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
RsvpSwitchOption(
|
||||||
|
selected = settings.rsvpShowFocusGuide.value,
|
||||||
|
titleRes = R.string.rsvp_focus_guide_option,
|
||||||
|
descRes = R.string.rsvp_focus_guide_option_desc,
|
||||||
|
onToggle = { settings.rsvpShowFocusGuide.update(!settings.rsvpShowFocusGuide.lastValue) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RsvpSwitchOption(
|
||||||
|
selected: Boolean,
|
||||||
|
titleRes: Int,
|
||||||
|
descRes: Int,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
) {
|
||||||
|
SwitchWithTitle(
|
||||||
|
selected = selected,
|
||||||
|
title = stringResource(titleRes),
|
||||||
|
description = stringResource(descRes),
|
||||||
|
onClick = onToggle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.ui.settings.reader.reading_speed.components
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.dueattendant149.bookshelf.R
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.components.settings.SliderWithTitle
|
||||||
|
import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RsvpWpmOption() {
|
||||||
|
val settings = LocalSettings.current
|
||||||
|
|
||||||
|
SliderWithTitle(
|
||||||
|
value = settings.rsvpWpm.value to stringResource(R.string.rsvp_wpm_label, settings.rsvpWpm.value),
|
||||||
|
fromValue = 100,
|
||||||
|
toValue = 900,
|
||||||
|
title = stringResource(id = R.string.rsvp_wpm_option),
|
||||||
|
onValueChange = { settings.rsvpWpm.update(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
16
app/src/main/res/drawable/audiobook_tab_filled.xml
Normal file
16
app/src/main/res/drawable/audiobook_tab_filled.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,1c-4.97,0 -9,4.03 -9,9v7c0,1.66 1.34,3 3,3h3v-8H5v-2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v2h-4v8h3c1.66,0 3,-1.34 3,-3v-7c0,-4.97 -4.03,-9 -9,-9zM11,19H8c-0.55,0 -1,-0.45 -1,-1v-4h4v5zM16,19h-3v-5h4v4c0,0.55 -0.45,1 -1,1z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/audiobook_tab_outlined.xml
Normal file
16
app/src/main/res/drawable/audiobook_tab_outlined.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19,14v3c0,1.1 -0.9,2 -2,2h-1v-4h3zM12,1c-4.97,0 -9,4.03 -9,9v7c0,1.66 1.34,3 3,3h3v-8H5v-2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v2h-4v8h3c1.66,0 3,-1.34 3,-3v-7c0,-4.97 -4.03,-9 -9,-9zM11,19H8c-0.55,0 -1,-0.45 -1,-1v-4h4v5zM16,19h-3v-5h4v4c0,0.55 -0.45,1 -1,1z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/book_tab_filled.xml
Normal file
16
app/src/main/res/drawable/book_tab_filled.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M21,5c-1.11,-0.35 -2.33,-0.5 -3.5,-0.5c-1.95,0 -4.05,0.4 -5.5,1.5c-1.45,-1.1 -3.55,-1.5 -5.5,-1.5S2.45,4.9 1,6v14.65c0,0.25 0.25,0.5 0.5,0.5c0.1,0 0.15,-0.05 0.25,-0.05C3.1,20.45 5.05,20 6.5,20c1.95,0 4.05,0.4 5.5,1.5c1.35,0.95 3.5,1.5 5.5,1.5c1.65,0 3.35,-0.3 4.75,-1.05c0.41,-0.21 0.75,-0.53 0.75,-0.93V6c0,-0.55 -0.55,-1.1 -2,-1.5zM21,18.5c-1.1,0.35 -2.3,0.5 -3.5,0.5c-1.95,0 -4.05,-0.4 -5.5,-1.5V8.65c1.45,1.1 3.55,1.5 5.5,1.5c1.2,0 2.4,-0.15 3.5,-0.5V18.5z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/book_tab_outlined.xml
Normal file
16
app/src/main/res/drawable/book_tab_outlined.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M21,4.33c-1.35,-0.45 -2.67,-0.68 -4,-0.68c-1.97,0 -4.1,0.39 -5.63,1.61C9.85,4.05 7.72,3.65 5.75,3.65S1.65,4.05 0.13,5.26l0,0C0.05,5.32 0,5.42 0,5.54v14.91c0,0.21 0.17,0.38 0.38,0.38c0.05,0 0.1,-0.01 0.15,-0.03c1.47,-0.77 3.51,-1.23 5.22,-1.23c1.97,0 4.1,0.39 5.63,1.61c0.09,0.07 0.2,0.11 0.31,0.11s0.23,-0.04 0.31,-0.11c1.53,-1.22 3.66,-1.61 5.63,-1.61c1.71,0 3.75,0.46 5.22,1.23c0.05,0.03 0.1,0.03 0.15,0.03c0.21,0 0.38,-0.17 0.38,-0.38V5.54C23,5.42 22.95,5.32 22.87,5.26C22.35,4.84 21.75,4.55 21,4.33zM17,18.12c-1.46,0 -3.09,0.26 -4.5,0.87V7.5c1.41,-0.61 3.04,-0.87 4.5,-0.87c1.25,0 2.57,0.19 3.75,0.56v10.93C19.57,17.93 18.25,18.12 17,18.12zM6.5,7.5c1.25,0 2.57,0.19 3.75,0.56v10.93c-1.18,-0.36 -2.5,-0.56 -3.75,-0.56c-1.46,0 -3.09,0.26 -4.5,0.87V8.06C3.41,7.76 4.92,7.5 6.5,7.5z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/search_tab_filled.xml
Normal file
16
app/src/main/res/drawable/search_tab_filled.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3 5.91,3 3,5.91 3,9.5S5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/search_tab_outlined.xml
Normal file
16
app/src/main/res/drawable/search_tab_outlined.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3 5.91,3 3,5.91 3,9.5S5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/settings_tab_filled.xml
Normal file
16
app/src/main/res/drawable/settings_tab_filled.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94 0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.49l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6 3.6,1.62 3.6,3.6 -1.62,3.6 -3.6,3.6z" />
|
||||||
|
</vector>
|
||||||
16
app/src/main/res/drawable/settings_tab_outlined.xml
Normal file
16
app/src/main/res/drawable/settings_tab_outlined.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!--
|
||||||
|
~ Book's Story — free and open-source Material You eBook reader.
|
||||||
|
~ Copyright (C) 2024-2026 Acclorite
|
||||||
|
~ SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M19.43,12.98c0.04,-0.32 0.07,-0.64 0.07,-0.98 0,-0.34 -0.03,-0.66 -0.07,-0.98l2.11,-1.65c0.19,-0.15 0.24,-0.42 0.11,-0.64l-2,-3.46c-0.12,-0.22 -0.39,-0.3 -0.61,-0.22l-2.49,1.01c-0.52,-0.4 -1.08,-0.73 -1.69,-0.98l-0.38,-2.65C14.46,2.18 14.25,2 14,2h-4c-0.25,0 -0.46,0.18 -0.5,0.42l-0.38,2.65c-0.61,0.25 -1.17,0.59 -1.69,0.98l-2.49,-1.01c-0.23,-0.09 -0.49,0 -0.61,0.22l-2,3.46c-0.13,0.22 -0.08,0.49 0.11,0.64L4.57,11c-0.04,0.32 -0.07,0.65 -0.07,0.98 0,0.33 0.03,0.66 0.07,0.98l-2.11,1.65c-0.19,0.15 -0.24,0.42 -0.11,0.64l2,3.46c0.12,0.22 0.39,0.3 0.61,0.22l2.49,-1.01c0.52,0.4 1.08,0.73 1.69,0.98l0.38,2.65c0.04,0.24 0.25,0.42 0.5,0.42h4c0.25,0 0.46,-0.18 0.5,-0.42l0.38,-2.65c0.61,-0.25 1.17,-0.59 1.69,-0.98l2.49,1.01c0.23,0.09 0.49,0 0.61,-0.22l2,-3.46c0.12,-0.22 0.07,-0.49 -0.11,-0.64l-2.12,-1.65zM12,15.5c-1.93,0 -3.5,-1.57 -3.5,-3.5s1.57,-3.5 3.5,-3.5 3.5,1.57 3.5,3.5 -1.57,3.5 -3.5,3.5z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -191,6 +191,8 @@
|
||||||
<string name="misc_reader_settings">Прочее</string>
|
<string name="misc_reader_settings">Прочее</string>
|
||||||
<string name="reading_speed_reader_settings">Скорость чтения</string>
|
<string name="reading_speed_reader_settings">Скорость чтения</string>
|
||||||
<string name="reading_mode_reader_settings">Режим чтения</string>
|
<string name="reading_mode_reader_settings">Режим чтения</string>
|
||||||
|
<string name="reader_pagination_option">Постраничный режим</string>
|
||||||
|
<string name="reader_pagination_option_desc">Перелистывание страниц вместо вертикальной прокрутки</string>
|
||||||
<string name="system_reader_settings">Система</string>
|
<string name="system_reader_settings">Система</string>
|
||||||
<string name="chapters_reader_settings">Главы</string>
|
<string name="chapters_reader_settings">Главы</string>
|
||||||
<string name="images_reader_settings">Изображения</string>
|
<string name="images_reader_settings">Изображения</string>
|
||||||
|
|
@ -535,6 +537,13 @@
|
||||||
<string name="library_content_desc">Ваша библиотека</string>
|
<string name="library_content_desc">Ваша библиотека</string>
|
||||||
<string name="history_content_desc">История чтения</string>
|
<string name="history_content_desc">История чтения</string>
|
||||||
<string name="browse_content_desc">Добавить книги</string>
|
<string name="browse_content_desc">Добавить книги</string>
|
||||||
|
<string name="books_screen">Книги</string>
|
||||||
|
<string name="books_content_desc">Удалённые книги</string>
|
||||||
|
<string name="audiobooks_screen">Аудиокниги</string>
|
||||||
|
<string name="audiobooks_content_desc">Удалённые аудиокниги</string>
|
||||||
|
<string name="search_screen">Поиск</string>
|
||||||
|
<string name="search_content_desc_network">Поиск в сети</string>
|
||||||
|
|
||||||
<string name="bookshelf_screen">Книжная полка</string>
|
<string name="bookshelf_screen">Книжная полка</string>
|
||||||
<string name="bookshelf_content_desc">Удалённая книжная полка</string>
|
<string name="bookshelf_content_desc">Удалённая книжная полка</string>
|
||||||
<string name="bookshelf_offline_only_label">Только оффлайн</string>
|
<string name="bookshelf_offline_only_label">Только оффлайн</string>
|
||||||
|
|
@ -544,7 +553,8 @@
|
||||||
<string name="bookshelf_cache_failed">Ошибка</string>
|
<string name="bookshelf_cache_failed">Ошибка</string>
|
||||||
<string name="bookshelf_download_action">Скачать</string>
|
<string name="bookshelf_download_action">Скачать</string>
|
||||||
<string name="bookshelf_delete_cache_action">Удалить</string>
|
<string name="bookshelf_delete_cache_action">Удалить</string>
|
||||||
<string name="bookshelf_play_audio_action">Играть</string>
|
<string name="bookshelf_play_audio_action">Слушать</string>
|
||||||
|
<string name="chapters_button">Главы</string>
|
||||||
<string name="progress_sync_failed">Не удалось синхронизировать прогресс чтения</string>
|
<string name="progress_sync_failed">Не удалось синхронизировать прогресс чтения</string>
|
||||||
<string name="playback_progress_sync_failed">Не удалось синхронизировать прогресс воспроизведения</string>
|
<string name="playback_progress_sync_failed">Не удалось синхронизировать прогресс воспроизведения</string>
|
||||||
<string name="arrow_content_desc">Стрелка</string>
|
<string name="arrow_content_desc">Стрелка</string>
|
||||||
|
|
@ -580,4 +590,34 @@
|
||||||
<string name="tts_create_job_failed">Не удалось создать TTS-задачу</string>
|
<string name="tts_create_job_failed">Не удалось создать TTS-задачу</string>
|
||||||
<string name="tts_download_failed">Ошибка загрузки TTS</string>
|
<string name="tts_download_failed">Ошибка загрузки TTS</string>
|
||||||
|
|
||||||
|
<!-- RSVP (speed reading) -->
|
||||||
|
<string name="rsvp_screen">Скорочтение</string>
|
||||||
|
<string name="rsvp_content_desc">Режим скорочтения</string>
|
||||||
|
<string name="rsvp_play">Играть</string>
|
||||||
|
<string name="rsvp_pause">Пауза</string>
|
||||||
|
<string name="rsvp_back">Назад</string>
|
||||||
|
<string name="rsvp_skip_back_10">−10 слов</string>
|
||||||
|
<string name="rsvp_skip_forward_10">+10 слов</string>
|
||||||
|
<string name="rsvp_wpm_label">%1$d слов/мин</string>
|
||||||
|
<string name="rsvp_progress_label">%1$d / %2$d слов</string>
|
||||||
|
<string name="rsvp_remaining_label">~%1$d мин осталось</string>
|
||||||
|
<string name="rsvp_open_from_reader">Скорочтение</string>
|
||||||
|
<string name="rsvp_open_from_reader_desc">Запустить режим RSVP</string>
|
||||||
|
<string name="rsvp_focus_guide_top">▲</string>
|
||||||
|
<string name="rsvp_focus_guide_bottom">▼</string>
|
||||||
|
<string name="rsvp_loading">Подготовка слов…</string>
|
||||||
|
<string name="rsvp_no_text">Не удалось извлечь текст для скорочтения</string>
|
||||||
|
<string name="rsvp_empty_book">В книге нет слов</string>
|
||||||
|
<string name="rsvp_settings_header">Скорочтение</string>
|
||||||
|
<string name="rsvp_wpm_option">Скорость (слов/мин)</string>
|
||||||
|
<string name="rsvp_font_size_option">Размер шрифта</string>
|
||||||
|
<string name="rsvp_pause_paragraph_option">Пауза между абзацами</string>
|
||||||
|
<string name="rsvp_pause_paragraph_option_desc">Доп. пауза в конце каждого абзаца</string>
|
||||||
|
<string name="rsvp_pause_chapter_option">Пауза между главами</string>
|
||||||
|
<string name="rsvp_pause_chapter_option_desc">Длинная пауза после заголовка главы</string>
|
||||||
|
<string name="rsvp_pause_long_words_option">Замедление длинных слов</string>
|
||||||
|
<string name="rsvp_pause_long_words_option_desc">+30% времени на слова длиннее 9 букв</string>
|
||||||
|
<string name="rsvp_focus_guide_option">Показывать направляющие фокуса</string>
|
||||||
|
<string name="rsvp_focus_guide_option_desc">▲▼ метки над и под точкой фиксации</string>
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
@ -11,7 +11,10 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequ
|
||||||
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.LibraryResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.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.ProgressUpdateRequest
|
||||||
|
|
@ -102,6 +105,47 @@ class BookshelfApiServiceTest {
|
||||||
assertEquals(42, libraries[0].itemCount)
|
assertEquals(42, libraries[0].itemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getLibraryItems deserializes unified list with authors type and progress`() = runBlocking {
|
||||||
|
server.enqueue(jsonResponse("""
|
||||||
|
{
|
||||||
|
"library": {"id":"lib-1","name":"Books","media_type":"book","item_count":2},
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id":"e-1",
|
||||||
|
"title":"Ebook",
|
||||||
|
"authors":["A. Ebooker"],
|
||||||
|
"type":"ebook",
|
||||||
|
"media_type":"book",
|
||||||
|
"library_id":"lib-1",
|
||||||
|
"cover_url":"https://example.com/e.jpg",
|
||||||
|
"progress": {"reading":{"itemId":"e-1","libraryId":"lib-1","scrollIndex":1,"scrollOffset":2,"progress":0.5,"updatedAt":1000}}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id":"a-1",
|
||||||
|
"title":"Audiobook",
|
||||||
|
"authors":["B. Audio"],
|
||||||
|
"type":"audiobook",
|
||||||
|
"media_type":"book",
|
||||||
|
"library_id":"lib-1",
|
||||||
|
"cover_url":"https://example.com/a.jpg",
|
||||||
|
"duration":123.0,
|
||||||
|
"progress": {"playback":{"itemId":"a-1","libraryId":"lib-1","current_file":"t.m4b","current_position":10,"duration":100,"updatedAt":2000}}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
""".trimIndent()))
|
||||||
|
val response: Response<LibraryItemsResponse> = service.getLibraryItems("lib-1")
|
||||||
|
assertTrue(response.isSuccessful)
|
||||||
|
val items: List<UnifiedItemResponse> = response.body()!!.items
|
||||||
|
assertEquals(2, items.size)
|
||||||
|
assertEquals("ebook", items[0].type)
|
||||||
|
assertEquals("audiobook", items[1].type)
|
||||||
|
assertEquals(listOf("A. Ebooker"), items[0].authors)
|
||||||
|
assertEquals(0.5f, items[0].progress?.reading?.progress)
|
||||||
|
assertEquals(10L, items[1].progress?.playback?.currentPosition)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `searchLibrary deserializes correctly`() = runBlocking {
|
fun `searchLibrary deserializes correctly`() = runBlocking {
|
||||||
server.enqueue(jsonResponse("""
|
server.enqueue(jsonResponse("""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue