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:
Atte149 2026-06-21 19:45:08 +03:00
parent 71537406c6
commit f7307f4199
98 changed files with 5866 additions and 893 deletions

View file

@ -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` (100900), `rsvpFontSize` (2496 sp), `rsvpPauseOnParagraph`, `rsvpPauseOnChapter`, `rsvpPauseOnLongWords`, `rsvpShowFocusGuide`.
- Phase 9 (reader pagination + polish):
- Kindle-style paginated reading mode with `HorizontalPager`.
- Volume keys (Up/Down) mapped to page navigation via `VolumeKeyWindowCallback`.
- Pagination measurement/render synchronization: exact page geometry, real inter-item spacing, image sizing, chapter title extras.
- Algorithm improvements: `TextLayoutResult.getLineForVerticalPosition()` split point, better widow/orphan handling.
- Phase 10 (bugfixes):
- Fixed search crash caused by duplicate LazyColumn keys when search results have empty `guid`.
- Fixed `BookCard` intrinsic-measurement crash risk by switching cover to fixed-height vertical variant.
- Phase 11 (audio player hardening):
- ExoPlayer streaming cache via `SimpleCache` + `CacheDataSource` (512 MB external cache).
- Unified `AuthorizationInterceptor` for player, backed by `PlaybackAuthProvider` refreshed from `ServerSettings`.
- Current APK: `KRait-debug-27.apk` (1.9.11) on `books.dueattendant149.org/download`.
- Next: final APK rebuild after pushing to Forgejo.
## WARNs ## WARNs

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,156 +38,275 @@ 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()
val token = serverSettings.getAbsToken()
if (url.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Audiobookshelf server is not configured.")
}
val client = apiFactory.provideClient(url, token)
?: throw IllegalStateException("Could not create Audiobookshelf client.")
val response = client.downloadBook(book.remoteId)
if (!response.isSuccessful) {
throw IllegalStateException("Download failed: ${response.code()}")
}
val body = response.body()
?: throw IllegalStateException("Download response body is empty.")
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
val fileName = guessFileName(response, book.remoteId)
val file = File(ebooksDir, fileName)
body.byteStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
val localBook = if (existing != null) {
book.copy(
id = existing.id,
filePath = file.absolutePath,
scrollIndex = existing.scrollIndex,
scrollOffset = existing.scrollOffset,
progress = existing.progress,
categories = existing.categories,
)
} else {
book.copy(filePath = file.absolutePath)
}
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
?: throw IllegalStateException("Could not insert or update book.")
database.ebookFileDao.insert(
EbookFileEntity(
bookId = inserted.id,
fileId = book.remoteId,
format = file.extension,
localPath = file.absolutePath,
)
)
cachedFileDao.insert(
CachedFileEntity(
bookId = inserted.id,
type = "ebook",
remoteUrl = "$url/api/items/${book.remoteId}/download",
localPath = file.absolutePath,
status = "completed",
progress = 1f,
)
)
inserted.id
} }
if (book.hasAudio) {
cacheAudiobook(book, localBookId)
}
localBookId
}
}.onFailure {
Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it)
}
private suspend fun insertLocalBook(book: Book): Int {
database.bookDao.insertBook(bookMapper.toBookEntity(book))
return database.bookDao.findBookByRemoteId(book.remoteId)?.id
?: throw IllegalStateException("Could not insert book.")
}
private suspend fun cacheEbook(book: Book, bookId: Int) {
val existingEbook = database.ebookFileDao.getByBookId(bookId)
if (existingEbook?.localPath != null && File(existingEbook.localPath).exists()) {
updateBookFilePath(book, existingEbook.localPath)
return
} }
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching { val bookshelfUrl = serverSettings.getBookshelfUrl()
withContext(Dispatchers.IO) { ?: throw IllegalStateException("Bookshelf API URL is not configured.")
val book = database.bookDao.findBookByRemoteId(remoteId) val client = apiFactory.provideClient(bookshelfUrl)
?: throw IllegalStateException("Book not found") ?: throw IllegalStateException("Could not create Bookshelf API client.")
val response = client.downloadEbook(book.remoteId)
if (!response.isSuccessful) {
throw IllegalStateException("Ebook download failed: ${response.code()}")
}
val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.")
val file = saveFileToDir(
body,
dirName = "ebooks",
fileName = guessFileName(response, book.remoteId)
)
database.ebookFileDao.insert(
EbookFileEntity(
bookId = bookId,
fileId = book.remoteId,
format = file.extension,
localPath = file.absolutePath,
)
)
cachedFileDao.insert(
CachedFileEntity(
bookId = bookId,
type = "ebook",
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook",
localPath = file.absolutePath,
status = "completed",
progress = 1f,
)
)
updateBookFilePath(book, file.absolutePath)
}
private suspend fun cacheAudiobook(book: Book, bookId: Int) {
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
val tracks = tracksResult.getOrThrow()
if (tracks.isEmpty()) {
Log.w("CacheRepository", "No audio tracks for ${book.remoteId}")
return
}
val bookshelfUrl = serverSettings.getBookshelfUrl()
?: throw IllegalStateException("Bookshelf API URL is not configured.")
val client = apiFactory.provideClient(bookshelfUrl)
?: throw IllegalStateException("Could not create Bookshelf API client.")
// Persist track metadata if missing.
val existingTracks = audioFileDao.getByBookId(bookId)
if (existingTracks.isEmpty()) {
audioFileDao.insertAll(
tracks.mapIndexed { index, track ->
AudioFileEntity(
bookId = bookId,
fileId = track.fileId,
remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
duration = track.durationMs / 1000.0,
order = index,
)
}
)
}
tracks.forEachIndexed { index, track ->
val cached = audioFileDao.getByBookId(bookId)
.firstOrNull { it.fileId == track.fileId }
if (cached?.localPath != null && File(cached.localPath).exists()) {
updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath)
return@forEachIndexed
}
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading")
val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}"
val response = client.downloadAudioFile(book.remoteId, track.fileId)
if (!response.isSuccessful) {
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
throw IllegalStateException("Audio track $index download failed: ${response.code()}")
}
val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
audioFileDao.update(
(cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId))
.copy(localPath = file.absolutePath)
)
val overallProgress = (index + 1).toFloat() / tracks.size
updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed")
cachedFileDao.insert(
CachedFileEntity(
bookId = bookId,
type = "audio_${track.fileId}",
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
localPath = file.absolutePath,
status = "completed",
progress = overallProgress,
)
)
}
}
private suspend fun updateAudioTrackProgress(
bookId: Int,
fileId: String,
progress: Float,
localPath: String?,
status: String = "completed",
) {
val type = "audio_$fileId"
val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull()
val entity = CachedFileEntity(
id = existing?.id ?: 0,
bookId = bookId,
type = type,
remoteUrl = existing?.remoteUrl ?: "",
localPath = localPath ?: existing?.localPath,
status = status,
progress = progress,
)
if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
}
private suspend fun updateBookFilePath(book: Book, path: String) {
val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
if (existing.filePath != path) {
database.bookDao.updateBook(
bookMapper.toBookEntity(
bookMapper.toBook(existing).copy(filePath = path)
)
)
}
}
private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File {
val dir = File(application.filesDir, dirName).apply { mkdirs() }
val file = File(dir, fileName)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
return file
}
private fun guessAudioExtension(fileNameOrTitle: String): String {
val name = fileNameOrTitle.lowercase()
return when {
name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b"
name.endsWith(".mp3") -> "mp3"
name.endsWith(".flac") -> "flac"
name.endsWith(".ogg") -> "ogg"
name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b"
else -> "mp3"
}
}
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val book = database.bookDao.findBookByRemoteId(remoteId)
if (book != null) {
val ebook = database.ebookFileDao.getByBookId(book.id) val ebook = database.ebookFileDao.getByBookId(book.id)
val cachedFiles = cachedFileDao.getByBookId(book.id) val cachedFiles = cachedFileDao.getByBookId(book.id)
val audioFiles = audioFileDao.getByBookId(book.id)
ebook?.localPath?.let { File(it).delete() } ebook?.localPath?.let { File(it).delete() }
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() } cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
database.ebookFileDao.deleteByBookId(book.id) database.ebookFileDao.deleteByBookId(book.id)
cachedFileDao.deleteByBookId(book.id) cachedFileDao.deleteByBookId(book.id)
audioFileDao.deleteByBookId(book.id)
} }
} }
}.onFailure {
Log.e("CacheRepository", "deleteCache failed for $remoteId", it)
}
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow { override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
val book = database.bookDao.findBookByRemoteId(remoteId) val book = database.bookDao.findBookByRemoteId(remoteId)
if (book == null) { if (book == null) {
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList())) emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
return@flow return@flow
}
cachedFileDao.observeByBookId(book.id).collect { entities ->
val files = entities.map {
CachedFile(
type = it.type,
status = it.status,
progress = it.progress,
localPath = it.localPath
)
}
val state = when {
entities.isEmpty() -> CacheState.NONE
entities.all { it.status == "completed" } -> CacheState.COMPLETED
entities.any { it.status == "failed" } -> CacheState.FAILED
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
entities.any { it.status == "pending" } -> CacheState.PENDING
else -> CacheState.NONE
}
val progress = if (entities.isEmpty()) {
0f
} else {
entities.map { it.progress }.average().toFloat()
}
emit(CacheStatus(remoteId, state, progress, files))
}
} }
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String { cachedFileDao.observeByBookId(book.id).collect { entities ->
val contentDisposition = response.headers()["Content-Disposition"] val files = entities.map {
val mimeType = response.body()?.contentType()?.toString() CachedFile(
val url = response.raw().request.url.toString() type = it.type,
val name = URLUtil.guessFileName(url, contentDisposition, mimeType) status = it.status,
return if (name.contains(".")) name else "$remoteId.epub" progress = it.progress,
localPath = it.localPath
)
}
val state = when {
entities.isEmpty() -> CacheState.NONE
entities.all { it.status == "completed" } -> CacheState.COMPLETED
entities.any { it.status == "failed" } -> CacheState.FAILED
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
entities.any { it.status == "pending" } -> CacheState.PENDING
else -> CacheState.NONE
}
val progress = if (entities.isEmpty()) 0f else entities.map { it.progress }.average().toFloat()
emit(CacheStatus(remoteId, state, progress, files))
} }
} }
private fun guessFileName(response: Response<ResponseBody>, remoteId: String): String {
val contentDisposition = response.headers()["Content-Disposition"]
val mimeType = response.body()?.contentType()?.toString()
val url = response.raw().request.url.toString()
val name = android.webkit.URLUtil.guessFileName(url, contentDisposition, mimeType)
return if (name.contains(".")) name else "$remoteId.epub"
}
/** Public accessor used by the player to read already-cached tracks. */
suspend fun getCachedTracks(bookId: Int): List<AudioFileEntity> =
audioFileDao.getCachedByBookId(bookId)
/** Public accessor used by the player to resolve a cached local file for streaming. */
suspend fun findCachedAudioFile(bookId: Int, fileId: String): File? {
return audioFileDao.getByBookId(bookId)
.firstOrNull { it.fileId == fileId }
?.localPath
?.let { File(it).takeIf { f -> f.exists() } }
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:
* 10, 20, 31, 41, 51, 62, 72, 82, 92, 103, 113, 123, 133.
*/
object OrpTable {
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
fun pivotFor(word: String): Int {
val len = word.length
if (len == 0) return 0
if (len <= TABLE.size) return TABLE[len - 1]
return (len * 0.3f).toInt().coerceIn(1, len - 1)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,24 +115,34 @@ 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 {
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase( val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
it.book.scrollIndex, it.book.scrollIndex,
it.text it.text
) )
it.copy( it.copy(
currentChapter = currentChapter, currentChapter = currentChapter,
currentChapterProgress = currentChapterProgress, currentChapterProgress = currentChapterProgress,
isLoading = false, isLoading = false,
errorMessage = null errorMessage = null
) )
}
} }
} }
@ -230,14 +244,69 @@ 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()) {
val targetPage = (event.progress * state.pages.lastIndex).roundToInt()
onEvent(ReaderEvent.OnChangePage(targetPage))
} else {
val scrollTo = (state.text.lastIndex * event.progress).roundToInt()
state.listState.requestScrollToItem(
index = scrollTo,
scrollOffset = 0
)
onEvent(ReaderEvent.OnUpdateChapter(scrollTo))
}
}
}
_state.value.listState.requestScrollToItem( is ReaderEvent.OnChangePage -> {
index = scrollTo, withContext(Dispatchers.Default) {
scrollOffset = 0 val state = _state.value
val pages = state.pages
if (pages.isEmpty()) return@withContext
val page = event.page.coerceIn(0, pages.lastIndex)
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
pageStart,
state.text
) )
onEvent(ReaderEvent.OnUpdateChapter(scrollTo)) _state.update {
it.copy(
currentPage = page,
currentChapter = currentChapter,
currentChapterProgress = currentChapterProgress,
book = it.book.copy(
progress = calculateProgress(page),
scrollIndex = pageStart,
scrollOffset = 0
)
)
}
updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
}
}
is ReaderEvent.OnPagesComputed -> {
withContext(Dispatchers.Default) {
val pages = event.pages
if (pages.isEmpty()) {
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
return@withContext
}
// Restore the page that contains the saved scrollIndex.
val savedIndex = _state.value.book.scrollIndex
val targetPage = pages
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
.takeIf { it >= 0 }
?: 0
_state.update { it.copy(pages = pages, currentPage = targetPage) }
} }
} }
@ -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,22 +557,31 @@ 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 page = pageOrIndex ?: state.currentPage
return page.coerceIn(0, state.pages.lastIndex)
.div(state.pages.lastIndex.toFloat())
.coerceAndPreventNaN()
}
val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress
if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f
return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) val index = pageOrIndex ?: state.listState.firstVisibleItemIndex
.div(_state.value.text.lastIndex.toFloat()) if (index == 0) return 0f
.coerceAndPreventNaN()
val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
} }
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) { private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {

View file

@ -8,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,32 +532,44 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
fullscreen = settings.fullscreen.value fullscreen = settings.fullscreen.value
) )
ReaderContent( Box(
book = state.value.book, modifier = Modifier
text = state.value.text, .focusable()
bottomSheet = state.value.bottomSheet, ) {
drawer = state.value.drawer, ReaderContent(
listState = listState, book = state.value.book,
currentChapter = state.value.currentChapter, text = state.value.text,
nestedScrollConnection = nestedScrollConnection.value, bottomSheet = state.value.bottomSheet,
fastColorPresetChange = settings.fastColorPresetChange.value, drawer = state.value.drawer,
perceptionExpander = settings.perceptionExpander.value, listState = listState,
perceptionExpanderPadding = perceptionExpanderPadding, currentChapter = state.value.currentChapter,
perceptionExpanderThickness = perceptionExpanderThickness, nestedScrollConnection = nestedScrollConnection.value,
horizontalLimiter = settings.horizontalLimiter.value, fastColorPresetChange = settings.fastColorPresetChange.value,
horizontalLimiterHeight = horizontalLimiterHeight, perceptionExpander = settings.perceptionExpander.value,
horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value, perceptionExpanderPadding = perceptionExpanderPadding,
horizontalLimiterRuler = settings.horizontalLimiterRuler.value, perceptionExpanderThickness = perceptionExpanderThickness,
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, horizontalLimiter = settings.horizontalLimiter.value,
horizontalLimiterDimming = settings.horizontalLimiterDimming.value, horizontalLimiterHeight = horizontalLimiterHeight,
currentChapterProgress = state.value.currentChapterProgress, horizontalLimiterVerticalOffset = settings.horizontalLimiterVerticalOffset.value,
isLoading = state.value.isLoading, horizontalLimiterRuler = settings.horizontalLimiterRuler.value,
errorMessage = state.value.errorMessage, horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
checkpoints = state.value.checkpoints, horizontalLimiterDimming = settings.horizontalLimiterDimming.value,
showMenu = state.value.showMenu, currentChapterProgress = state.value.currentChapterProgress,
lockMenu = state.value.lockMenu, isLoading = state.value.isLoading,
contentPadding = contentPadding, errorMessage = state.value.errorMessage,
verticalPadding = verticalPadding, checkpoints = state.value.checkpoints,
showMenu = state.value.showMenu,
lockMenu = state.value.lockMenu,
readerPagination = readerPagination,
pages = state.value.pages,
currentPage = state.value.currentPage,
onPageChanged = { page ->
if (state.value.pages.isNotEmpty()) {
screenModel.onEvent(ReaderEvent.OnChangePage(page))
}
},
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = settings.horizontalGesture.value, horizontalGesture = settings.horizontalGesture.value,
horizontalGestureScroll = settings.horizontalGestureScroll.value, horizontalGestureScroll = settings.horizontalGestureScroll.value,
horizontalGestureSensitivity = horizontalGestureSensitivity, horizontalGestureSensitivity = horizontalGestureSensitivity,
@ -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
) )
}
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
@ -138,13 +143,17 @@ fun ReaderBottomBar(
} }
}, },
slider = { slider = {
ReaderBottomBarSlider( ReaderBottomBarSlider(
book = book, book = book,
lockMenu = lockMenu, lockMenu = lockMenu,
listState = listState, listState = listState,
scroll = scroll, readerPagination = readerPagination,
changeProgress = changeProgress pages = pages,
) currentPage = currentPage,
onChangePage = onChangePage,
scroll = scroll,
changeProgress = changeProgress
)
}, },
indicator = { indicator = {
ReaderBottomBarCheckpointsIndicator( ReaderBottomBarCheckpointsIndicator(
@ -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(

View file

@ -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(
@ -120,29 +125,33 @@ fun ReaderContent(
) )
if (isLoading || errorMessage == null) { if (isLoading || errorMessage == null) {
ReaderScaffold( ReaderScaffold(
book = book, book = book,
text = text, text = text,
listState = listState, listState = listState,
currentChapter = currentChapter, currentChapter = currentChapter,
nestedScrollConnection = nestedScrollConnection, nestedScrollConnection = nestedScrollConnection,
fastColorPresetChange = fastColorPresetChange, fastColorPresetChange = fastColorPresetChange,
perceptionExpander = perceptionExpander, perceptionExpander = perceptionExpander,
perceptionExpanderPadding = perceptionExpanderPadding, perceptionExpanderPadding = perceptionExpanderPadding,
perceptionExpanderThickness = perceptionExpanderThickness, perceptionExpanderThickness = perceptionExpanderThickness,
horizontalLimiter = horizontalLimiter, horizontalLimiter = horizontalLimiter,
horizontalLimiterHeight = horizontalLimiterHeight, horizontalLimiterHeight = horizontalLimiterHeight,
horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset, horizontalLimiterVerticalOffset = horizontalLimiterVerticalOffset,
horizontalLimiterRuler = horizontalLimiterRuler, horizontalLimiterRuler = horizontalLimiterRuler,
horizontalLimiterRulerThickness = horizontalLimiterRulerThickness, horizontalLimiterRulerThickness = horizontalLimiterRulerThickness,
horizontalLimiterDimming = horizontalLimiterDimming, horizontalLimiterDimming = horizontalLimiterDimming,
currentChapterProgress = currentChapterProgress, currentChapterProgress = currentChapterProgress,
isLoading = isLoading, isLoading = isLoading,
checkpoints = checkpoints, checkpoints = checkpoints,
showMenu = showMenu, showMenu = showMenu,
lockMenu = lockMenu, lockMenu = lockMenu,
contentPadding = contentPadding, readerPagination = readerPagination,
verticalPadding = verticalPadding, pages = pages,
currentPage = currentPage,
onPageChanged = onPageChanged,
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = horizontalGesture, horizontalGesture = horizontalGesture,
horizontalGestureScroll = horizontalGestureScroll, horizontalGestureScroll = horizontalGestureScroll,
horizontalGestureSensitivity = horizontalGestureSensitivity, horizontalGestureSensitivity = horizontalGestureSensitivity,
@ -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(

View file

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

View file

@ -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,90 +166,140 @@ fun ReaderLayout(
) )
.padding(contentPadding) .padding(contentPadding)
.padding(vertical = verticalPadding) .padding(vertical = verticalPadding)
.readerHorizontalGesture( .then(
listState = listState, if (!readerPagination) {
horizontalGesture = horizontalGesture, Modifier.readerHorizontalGesture(
horizontalGestureScroll = horizontalGestureScroll, listState = listState,
horizontalGestureSensitivity = horizontalGestureSensitivity, horizontalGesture = horizontalGesture,
horizontalGestureAlphaAnim = horizontalGestureAlphaAnim, horizontalGestureScroll = horizontalGestureScroll,
horizontalGesturePullAnim = horizontalGesturePullAnim, horizontalGestureSensitivity = horizontalGestureSensitivity,
isLoading = isLoading horizontalGestureAlphaAnim = horizontalGestureAlphaAnim,
horizontalGesturePullAnim = horizontalGesturePullAnim,
isLoading = isLoading
)
} else Modifier
) )
) { ) {
LazyColumnWithScrollbar( if (readerPagination && pages.isNotEmpty()) {
state = listState, ReaderPaginationLayout(
enableScrollbar = false, pages = pages,
parentModifier = Modifier.weight(1f), currentPage = currentPage,
modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(0.dp),
userScrollEnabled = !horizontalGestureDisableScrolling, verticalPadding = verticalPadding,
contentPadding = PaddingValues( highlightedReading = highlightedReading,
top = (WindowInsets.displayCutout.asPaddingValues() highlightedReadingThickness = highlightedReadingThickness,
.calculateTopPadding() + paragraphHeight) progress = progress,
.coerceAtLeast(18.dp), progressBar = progressBar,
bottom = (WindowInsets.displayCutout.asPaddingValues() progressBarPadding = progressBarPadding,
.calculateBottomPadding() + paragraphHeight) progressBarAlignment = progressBarAlignment,
.coerceAtLeast(18.dp), 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 {
itemsIndexed( LazyColumnWithScrollbar(
text, state = listState,
key = { index, _ -> index } enableScrollbar = false,
) { index, entry -> parentModifier = Modifier.weight(1f),
val previousEntry = text.getOrNull(index - 1) modifier = Modifier.fillMaxSize(),
userScrollEnabled = !horizontalGestureDisableScrolling,
contentPadding = PaddingValues(
top = (WindowInsets.displayCutout.asPaddingValues()
.calculateTopPadding() + paragraphHeight)
.coerceAtLeast(18.dp),
bottom = (WindowInsets.displayCutout.asPaddingValues()
.calculateBottomPadding() + paragraphHeight)
.coerceAtLeast(18.dp),
)
) {
itemsIndexed(
text,
key = { index, _ -> index }
) { index, entry ->
val previousEntry = text.getOrNull(index - 1)
when { when {
!images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@itemsIndexed !images && (entry is ReaderText.Image || previousEntry is ReaderText.Image) -> return@itemsIndexed
!imagesCaptions && previousEntry is ReaderText.Image -> return@itemsIndexed !imagesCaptions && previousEntry is ReaderText.Image -> return@itemsIndexed
else -> { else -> {
SpacedItem( SpacedItem(
index = index, index = index,
spacing = paragraphHeight spacing = paragraphHeight
) { ) {
ReaderLayoutText( ReaderLayoutText(
showMenu = showMenu, showMenu = showMenu,
entry = entry, entry = entry,
imagesCornersRoundness = imagesCornersRoundness, imagesCornersRoundness = imagesCornersRoundness,
imagesAlignment = imagesAlignment, imagesAlignment = imagesAlignment,
imagesWidth = imagesWidth, imagesWidth = imagesWidth,
imagesColorEffects = imagesColorEffects, imagesColorEffects = imagesColorEffects,
fontFamily = fontFamily, fontFamily = fontFamily,
fontColor = fontColor, fontColor = fontColor,
lineHeight = lineHeight, lineHeight = lineHeight,
fontThickness = fontThickness, fontThickness = fontThickness,
fontStyle = fontStyle, fontStyle = fontStyle,
chapterTitleAlignment = chapterTitleAlignment, chapterTitleAlignment = chapterTitleAlignment,
textAlignment = textAlignment, textAlignment = textAlignment,
horizontalAlignment = horizontalAlignment, horizontalAlignment = horizontalAlignment,
fontSize = fontSize, fontSize = fontSize,
letterSpacing = letterSpacing, letterSpacing = letterSpacing,
sidePadding = sidePadding, sidePadding = sidePadding,
paragraphIndentation = paragraphIndentation, paragraphIndentation = paragraphIndentation,
doubleClickTranslation = doubleClickTranslation, doubleClickTranslation = doubleClickTranslation,
highlightedReading = highlightedReading, highlightedReading = highlightedReading,
highlightedReadingThickness = highlightedReadingThickness, highlightedReadingThickness = highlightedReadingThickness,
toolbarHidden = toolbarHidden, toolbarHidden = toolbarHidden,
openTranslator = openTranslator, openTranslator = openTranslator,
menuVisibility = menuVisibility menuVisibility = menuVisibility
) )
}
} }
} }
} }
} }
}
AnimatedVisibility( AnimatedVisibility(
visible = !showMenu && progressBar, visible = !showMenu && progressBar,
enter = slideInVertically { it } + expandVertically(), enter = slideInVertically { it } + expandVertically(),
exit = slideOutVertically { it } + shrinkVertically() exit = slideOutVertically { it } + shrinkVertically()
) { ) {
ReaderProgressBar( ReaderProgressBar(
progress = progress, progress = progress,
progressBarPadding = progressBarPadding, progressBarPadding = progressBarPadding,
progressBarAlignment = progressBarAlignment, progressBarAlignment = progressBarAlignment,
progressBarFontSize = progressBarFontSize, progressBarFontSize = progressBarFontSize,
fontColor = fontColor, fontColor = fontColor,
sidePadding = sidePadding sidePadding = sidePadding
) )
}
} }
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = {
NavigatorBackIconButton( if (!isRoot) {
navigateBack = navigateBack NavigatorBackIconButton(
) navigateBack = navigateBack
)
}
}, },
actions = { actions = {
IconButton( IconButton(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

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

View file

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