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.
- TTS jobs screen via bookshelf-api.
- Reading and playback progress sync with periodic WorkManager worker.
- Current: `feature/ui-polish` builds and produces `app-debug.apk` (~58 MB). Debug APK uploaded as `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`).
- 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

View file

@ -19,8 +19,8 @@ android {
applicationId = "org.dueattendant149.bookshelf"
minSdk = 31
targetSdk = 36
versionCode = 14
versionName = "1.8.0"
versionCode = 27
versionName = "1.9.11"
vectorDrawables {
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 dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookshelf.core.crash.CrashHandler
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
import javax.inject.Inject
@ -24,10 +25,14 @@ class Application : Application(), Configuration.Provider {
@Inject
lateinit var workManager: WorkManager
@Inject
lateinit var serverStatusMonitor: ServerStatusMonitor
override fun onCreate() {
super.onCreate()
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
ProgressSyncWorker.schedule(workManager)
serverStatusMonitor.start()
}
override val workManagerConfiguration: Configuration

View file

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

View file

@ -32,6 +32,8 @@ data class BookEntity(
val libraryId: String = "",
@ColumnInfo(defaultValue = "")
val mediaType: String = "",
@ColumnInfo(defaultValue = "")
val itemType: String = "",
@ColumnInfo(defaultValue = "0")
val hasAudio: Boolean = false,
@ColumnInfo(defaultValue = "0")

View file

@ -34,7 +34,7 @@ import java.io.File
EbookFileEntity::class,
CachedFileEntity::class,
],
version = 18,
version = 19,
autoMigrations = [
AutoMigration(1, 2),
AutoMigration(2, 3),
@ -203,6 +203,14 @@ object DatabaseHelper {
@DeleteTable("CategorySortEntity")
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
val MANUAL_MIGRATION_18_19 = object : Migration(18, 19) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE BookEntity ADD COLUMN itemType TEXT NOT NULL DEFAULT ''"
)
}
}
val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(

View file

@ -29,6 +29,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
remoteId = book.remoteId,
libraryId = book.libraryId,
mediaType = book.mediaType,
itemType = book.itemType,
hasAudio = book.hasAudio,
hasEbook = book.hasEbook,
audioDuration = book.audioDuration,
@ -58,6 +59,7 @@ class BookMapperImpl @Inject constructor() : BookMapper {
remoteId = bookEntity.remoteId,
libraryId = bookEntity.libraryId,
mediaType = bookEntity.mediaType,
itemType = bookEntity.itemType,
hasAudio = bookEntity.hasAudio,
hasEbook = bookEntity.hasEbook,
audioDuration = bookEntity.audioDuration,

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
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import javax.inject.Inject
import javax.inject.Singleton
/**
* In-memory holder for the audiobookshelf bearer token used by playback
* components. [refresh] must be called after the token changes or before a
* fresh playback session starts.
*/
@Singleton
class PlaybackAuthProvider @Inject constructor() {
@Volatile
var token: String = ""
private set
suspend fun refresh(serverSettings: ServerSettings) {
token = serverSettings.getAbsToken() ?: ""
}
fun setToken(value: String) {
token = value.trim()
}
}

View file

@ -13,6 +13,7 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
import javax.inject.Singleton
@Module
@ -32,19 +33,7 @@ object PlaybackModule {
okHttpClient: OkHttpClient,
): ExoPlayer {
val client = okHttpClient.newBuilder()
.addInterceptor { chain ->
val request = chain.request()
val token = authProvider.token
if (token.isNotBlank()) {
chain.proceed(
request.newBuilder()
.header("Authorization", "Bearer $token")
.build()
)
} else {
chain.proceed(request)
}
}
.addInterceptor(AuthorizationInterceptor(authProvider::token))
.build()
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
import okhttp3.ResponseBody
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.AudioTrackResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
@ -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.TtsJobsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.YandexResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Multipart
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import retrofit2.http.POST
/**
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
@ -41,6 +48,24 @@ interface BookshelfApiService {
@GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>>
@Streaming
@GET("api/v1/books/{itemId}/ebook")
suspend fun downloadEbook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/v1/books/{itemId}/tracks")
suspend fun getAudioTracks(
@Path("itemId") itemId: String,
): Response<List<AudioTrackResponse>>
@Streaming
@GET("api/v1/books/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/v1/books/{itemId}")
suspend fun getBook(
@ -61,6 +86,11 @@ interface BookshelfApiService {
@Body request: PlaybackProgressUpdateRequest,
): Response<ResponseBody>
@GET("api/v1/books/library/{libraryId}/items")
suspend fun getLibraryItems(
@Path("libraryId") libraryId: String,
): Response<LibraryItemsResponse>
@GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary(
@Path("libraryId") libraryId: String,
@ -100,6 +130,13 @@ interface BookshelfApiService {
@Path("jobId") jobId: String,
): Response<ResponseBody>
@Multipart
@POST("api/v1/upload/book")
suspend fun uploadBook(
@Part file: MultipartBody.Part,
@Part("book_type") bookType: RequestBody,
): Response<UploadBookResponse>
// Downloads / sources
@POST("api/v1/download")
suspend fun startDownload(

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
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.domain.model.player.AudioTrack
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
@ -18,35 +18,30 @@ import javax.inject.Singleton
class AudiobookshelfRepositoryImpl
@Inject
constructor(
private val clientFactory: AudiobookshelfApiClientFactory,
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) : AudiobookshelfRepository {
override suspend fun getAudioTracks(itemId: String): Result<List<AudioTrack>> {
return runCatching {
val url = serverSettings.getAbsUrl()
val token = serverSettings.getAbsToken()
if (url.isNullOrBlank() || token.isNullOrBlank()) {
return Result.failure(IllegalStateException("Audiobookshelf not configured"))
val bookshelfUrl = serverSettings.getBookshelfUrl()
if (bookshelfUrl.isNullOrBlank()) {
return Result.failure(IllegalStateException("Bookshelf API URL is not configured"))
}
val client = clientFactory.provideClient(url, token)
?: return Result.failure(IllegalStateException("Failed to create ABS client"))
val client = clientFactory.provideClient(bookshelfUrl)
?: return Result.failure(IllegalStateException("Failed to create Bookshelf API client"))
val response = client.getItem(itemId)
val response = client.getAudioTracks(itemId)
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()
?: return Result.failure(RuntimeException("Empty item response"))
item.media?.audioFiles.orEmpty()
.mapIndexed { index, file ->
response.body().orEmpty()
.mapIndexed { index, track ->
AudioTrack(
fileId = file.ino,
title = file.metadata?.filename?.takeIf { it.isNotBlank() }
?: "Track ${index + 1}",
durationMs = (file.duration * 1000).toLong(),
fileId = track.fileId,
title = track.title.takeIf { it.isNotBlank() } ?: "Track ${index + 1}",
durationMs = (track.duration * 1000).toLong(),
order = index,
)
}

View file

@ -7,22 +7,26 @@
package org.dueattendant149.bookshelf.data.repository
import android.app.Application
import android.webkit.URLUtil
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
import okhttp3.ResponseBody
import org.dueattendant149.bookshelf.data.local.dto.AudioFileEntity
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import retrofit2.Response
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@ -34,82 +38,68 @@ class CacheRepositoryImpl
private val application: Application,
private val database: BookDatabase,
private val bookMapper: BookMapper,
private val apiFactory: AudiobookshelfApiClientFactory,
private val apiFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
private val audiobookshelfRepository: AudiobookshelfRepository,
) : CacheRepository {
private val cachedFileDao by lazy { database.cachedFileDao }
private val audioFileDao by lazy { database.audioFileDao }
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
withContext(Dispatchers.IO) {
require(book.hasEbook) { "Book has no ebook." }
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
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 &&
cachedEbook?.localPath != null &&
File(cachedEbook.localPath).exists()
) {
if (existing.filePath != cachedEbook.localPath) {
database.bookDao.updateBook(
bookMapper.toBookEntity(
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
)
)
}
return@withContext existing.id
if (book.hasEbook) {
cacheEbook(book, localBookId)
}
val url = serverSettings.getAbsUrl()
val token = serverSettings.getAbsToken()
if (url.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Audiobookshelf server is not configured.")
if (book.hasAudio) {
cacheAudiobook(book, localBookId)
}
val client = apiFactory.provideClient(url, token)
?: throw IllegalStateException("Could not create Audiobookshelf client.")
localBookId
}
}.onFailure {
Log.e("CacheRepository", "cacheBook failed for ${book.remoteId}", it)
}
val response = client.downloadBook(book.remoteId)
private suspend fun insertLocalBook(book: Book): Int {
database.bookDao.insertBook(bookMapper.toBookEntity(book))
return database.bookDao.findBookByRemoteId(book.remoteId)?.id
?: throw IllegalStateException("Could not insert book.")
}
private suspend fun cacheEbook(book: Book, bookId: Int) {
val existingEbook = database.ebookFileDao.getByBookId(bookId)
if (existingEbook?.localPath != null && File(existingEbook.localPath).exists()) {
updateBookFilePath(book, existingEbook.localPath)
return
}
val bookshelfUrl = serverSettings.getBookshelfUrl()
?: throw IllegalStateException("Bookshelf API URL is not configured.")
val client = apiFactory.provideClient(bookshelfUrl)
?: throw IllegalStateException("Could not create Bookshelf API client.")
val response = client.downloadEbook(book.remoteId)
if (!response.isSuccessful) {
throw IllegalStateException("Download failed: ${response.code()}")
throw IllegalStateException("Ebook download failed: ${response.code()}")
}
val body = response.body() ?: throw IllegalStateException("Ebook response body is empty.")
val body = response.body()
?: 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,
val file = saveFileToDir(
body,
dirName = "ebooks",
fileName = guessFileName(response, book.remoteId)
)
} 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,
bookId = bookId,
fileId = book.remoteId,
format = file.extension,
localPath = file.absolutePath,
@ -118,33 +108,158 @@ class CacheRepositoryImpl
cachedFileDao.insert(
CachedFileEntity(
bookId = inserted.id,
bookId = bookId,
type = "ebook",
remoteUrl = "$url/api/items/${book.remoteId}/download",
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/ebook",
localPath = file.absolutePath,
status = "completed",
progress = 1f,
)
)
inserted.id
updateBookFilePath(book, file.absolutePath)
}
private suspend fun cacheAudiobook(book: Book, bookId: Int) {
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
val tracks = tracksResult.getOrThrow()
if (tracks.isEmpty()) {
Log.w("CacheRepository", "No audio tracks for ${book.remoteId}")
return
}
val bookshelfUrl = serverSettings.getBookshelfUrl()
?: throw IllegalStateException("Bookshelf API URL is not configured.")
val client = apiFactory.provideClient(bookshelfUrl)
?: throw IllegalStateException("Could not create Bookshelf API client.")
// Persist track metadata if missing.
val existingTracks = audioFileDao.getByBookId(bookId)
if (existingTracks.isEmpty()) {
audioFileDao.insertAll(
tracks.mapIndexed { index, track ->
AudioFileEntity(
bookId = bookId,
fileId = track.fileId,
remotePath = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
duration = track.durationMs / 1000.0,
order = index,
)
}
)
}
tracks.forEachIndexed { index, track ->
val cached = audioFileDao.getByBookId(bookId)
.firstOrNull { it.fileId == track.fileId }
if (cached?.localPath != null && File(cached.localPath).exists()) {
updateAudioTrackProgress(bookId, track.fileId, 1f, cached.localPath)
return@forEachIndexed
}
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "downloading")
val fileName = "track_${String.format("%03d", index)}_${track.fileId}.${guessAudioExtension(track.title)}"
val response = client.downloadAudioFile(book.remoteId, track.fileId)
if (!response.isSuccessful) {
updateAudioTrackProgress(bookId, track.fileId, 0f, null, "failed")
throw IllegalStateException("Audio track $index download failed: ${response.code()}")
}
val body = response.body() ?: throw IllegalStateException("Audio track $index body is empty")
val file = saveFileToDir(body, dirName = "audiobooks/${book.remoteId}", fileName = fileName)
audioFileDao.update(
(cached ?: AudioFileEntity(bookId = bookId, fileId = track.fileId))
.copy(localPath = file.absolutePath)
)
val overallProgress = (index + 1).toFloat() / tracks.size
updateAudioTrackProgress(bookId, track.fileId, 1f, file.absolutePath, "completed")
cachedFileDao.insert(
CachedFileEntity(
bookId = bookId,
type = "audio_${track.fileId}",
remoteUrl = "$bookshelfUrl/api/v1/books/${book.remoteId}/file/${track.fileId}",
localPath = file.absolutePath,
status = "completed",
progress = overallProgress,
)
)
}
}
private suspend fun updateAudioTrackProgress(
bookId: Int,
fileId: String,
progress: Float,
localPath: String?,
status: String = "completed",
) {
val type = "audio_$fileId"
val existing = cachedFileDao.getByBookIdAndType(bookId, type).firstOrNull()
val entity = CachedFileEntity(
id = existing?.id ?: 0,
bookId = bookId,
type = type,
remoteUrl = existing?.remoteUrl ?: "",
localPath = localPath ?: existing?.localPath,
status = status,
progress = progress,
)
if (existing != null) cachedFileDao.update(entity) else cachedFileDao.insert(entity)
}
private suspend fun updateBookFilePath(book: Book, path: String) {
val existing = database.bookDao.findBookByRemoteId(book.remoteId) ?: return
if (existing.filePath != path) {
database.bookDao.updateBook(
bookMapper.toBookEntity(
bookMapper.toBook(existing).copy(filePath = path)
)
)
}
}
private fun saveFileToDir(body: ResponseBody, dirName: String, fileName: String): File {
val dir = File(application.filesDir, dirName).apply { mkdirs() }
val file = File(dir, fileName)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
return file
}
private fun guessAudioExtension(fileNameOrTitle: String): String {
val name = fileNameOrTitle.lowercase()
return when {
name.endsWith(".m4b") || name.endsWith(".m4a") -> "m4b"
name.endsWith(".mp3") -> "mp3"
name.endsWith(".flac") -> "flac"
name.endsWith(".ogg") -> "ogg"
name.endsWith(".mp4") || name.endsWith(".aac") -> "m4b"
else -> "mp3"
}
}
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val book = database.bookDao.findBookByRemoteId(remoteId)
?: throw IllegalStateException("Book not found")
if (book != null) {
val ebook = database.ebookFileDao.getByBookId(book.id)
val cachedFiles = cachedFileDao.getByBookId(book.id)
val audioFiles = audioFileDao.getByBookId(book.id)
ebook?.localPath?.let { File(it).delete() }
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
audioFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
database.ebookFileDao.deleteByBookId(book.id)
cachedFileDao.deleteByBookId(book.id)
audioFileDao.deleteByBookId(book.id)
}
}
}.onFailure {
Log.e("CacheRepository", "deleteCache failed for $remoteId", it)
}
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
val book = database.bookDao.findBookByRemoteId(remoteId)
@ -170,20 +285,28 @@ class CacheRepositoryImpl
entities.any { it.status == "pending" } -> CacheState.PENDING
else -> CacheState.NONE
}
val progress = if (entities.isEmpty()) {
0f
} else {
entities.map { it.progress }.average().toFloat()
}
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 {
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 = URLUtil.guessFileName(url, contentDisposition, mimeType)
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
import android.util.Log
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.dueattendant149.bookshelf.core.ui.UIText
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UnifiedItemResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
@ -23,6 +35,8 @@ class RemoteLibraryRepositoryImpl
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
private val database: BookDatabase,
private val bookMapper: BookMapper,
) : RemoteLibraryRepository {
override suspend fun getLibraries(): Result<List<RemoteLibrary>> {
return runCatching {
@ -38,7 +52,42 @@ class RemoteLibraryRepositoryImpl
}
override suspend fun getBooks(libraryId: String): Result<List<Book>> {
return searchLibrary(libraryId, "")
return runCatching {
val client = client() ?: return Result.success(emptyList())
val response = client.getLibraryItems(libraryId)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Failed to load library items: ${response.code()}"))
}
val body = response.body() ?: LibraryItemsResponse()
val items = body.items.map { it.toBook() }
// Upsert each item into the local DB keyed by remoteId,
// preserving local file path/progress if the book already exists.
items.forEach { book ->
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
val toStore = if (existing != null) {
val existingBook = bookMapper.toBook(existing)
book.copy(
id = existingBook.id,
filePath = existingBook.filePath,
scrollIndex = existingBook.scrollIndex,
scrollOffset = existingBook.scrollOffset,
progress = existingBook.progress,
categories = existingBook.categories,
lastOpened = existingBook.lastOpened,
)
} else {
book
}
database.bookDao.insertBook(bookMapper.toBookEntity(toStore))
}
Result.success(items)
}.getOrElse {
Log.e("RemoteLibraryRepo", "getBooks failed", it)
Result.failure(it)
}
}
override suspend fun searchLibrary(
@ -57,6 +106,59 @@ class RemoteLibraryRepositoryImpl
}
}
override suspend fun search(request: SearchRequest): Result<SearchResponse> {
return runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.search(request)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Search failed: ${response.code()}"))
}
response.body() ?: SearchResponse()
}.onFailure {
Log.e("RemoteLibraryRepo", "search failed", it)
}
}
override suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
return runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.startDownload(request)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Download failed: ${response.code()}"))
}
response.body() ?: DownloadResponse()
}.onFailure {
Log.e("RemoteLibraryRepo", "startDownload failed", it)
}
}
override suspend fun listDownloads(): Result<DownloadsListResponse> {
return runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.listDownloads()
if (!response.isSuccessful) {
return Result.failure(RuntimeException("List downloads failed: ${response.code()}"))
}
response.body() ?: DownloadsListResponse()
}.onFailure {
Log.e("RemoteLibraryRepo", "listDownloads failed", it)
}
}
override suspend fun uploadBook(
file: MultipartBody.Part,
bookType: RequestBody,
): Result<UploadBookResponse> = runCatching {
val client = client() ?: return Result.failure(RuntimeException("Server not configured"))
val response = client.uploadBook(file, bookType)
if (!response.isSuccessful) {
return Result.failure(RuntimeException("Upload failed: ${response.code()}"))
}
response.body() ?: UploadBookResponse(success = false, errorMessage = "Empty response")
}.onFailure {
Log.e("RemoteLibraryRepo", "uploadBook failed", it)
}
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
private fun LibraryResponse.toDomain() =
@ -67,7 +169,54 @@ class RemoteLibraryRepositoryImpl
itemCount = itemCount,
)
private fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
private suspend fun UnifiedItemResponse.toBook(): Book {
val resolvedCoverUrl = resolveCoverUrl(coverUrl)
return Book(
id = 0,
title = title,
author = UIText.StringValue(
author.takeIf { it.isNotBlank() }
?: authors.filter { it.isNotBlank() }.joinToString(", ")
.takeIf { it.isNotBlank() }
?: ""
),
description = null,
filePath = "",
coverImage = null,
scrollIndex = progress?.reading?.scrollIndex ?: 0,
scrollOffset = progress?.reading?.scrollOffset ?: 0,
progress = progress?.reading?.progress ?: 0f,
lastOpened = null,
categories = emptyList(),
remoteId = id,
libraryId = libraryId,
mediaType = mediaType,
itemType = type,
hasAudio = type == "audiobook" || type == "podcast" || type == "hybrid",
hasEbook = type == "ebook" || type == "hybrid",
audioDuration = progress?.playback?.duration ?: duration.toLong(),
audioCurrentFile = progress?.playback?.currentFile ?: "",
audioCurrentPosition = progress?.playback?.currentPosition ?: 0L,
coverUrl = resolvedCoverUrl,
lastSyncedAt = System.currentTimeMillis(),
)
}
private suspend fun resolveCoverUrl(coverUrl: String): String {
if (coverUrl.isBlank()) return ""
val bookshelfUrl = serverSettings.getBookshelfUrl() ?: return coverUrl
val base = bookshelfUrl.trimEnd('/').substringBeforeLast('/')
return when {
coverUrl.startsWith("http://", ignoreCase = true) ||
coverUrl.startsWith("https://", ignoreCase = true) -> coverUrl
coverUrl.startsWith("/") -> "$base$coverUrl"
else -> "$base/$coverUrl"
}
}
private suspend fun org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.BookItemResponse.toBook() =
Book(
id = 0,
title = title,
@ -83,6 +232,7 @@ class RemoteLibraryRepositoryImpl
remoteId = id,
libraryId = libraryId,
mediaType = mediaType,
itemType = mediaType,
hasAudio = mediaType == "audiobook" || mediaType == "podcast",
hasEbook = mediaType == "book",
audioDuration = duration.toLong(),

View file

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

View file

@ -35,6 +35,7 @@ data class Book(
val remoteId: String = "",
val libraryId: String = "",
val mediaType: String = "",
val itemType: String = "",
val hasAudio: Boolean = false,
val hasEbook: Boolean = false,
val audioDuration: Long = 0L,
@ -59,6 +60,7 @@ data class Book(
remoteId = "",
libraryId = "",
mediaType = "",
itemType = "",
hasAudio = false,
hasEbook = false,
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
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
@ -13,4 +18,8 @@ interface RemoteLibraryRepository {
suspend fun getLibraries(): Result<List<RemoteLibrary>>
suspend fun getBooks(libraryId: String): Result<List<Book>>
suspend fun searchLibrary(libraryId: String, query: String): Result<List<Book>>
suspend fun search(request: SearchRequest): Result<SearchResponse>
suspend fun startDownload(request: org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequest): Result<org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse>
suspend fun listDownloads(): Result<org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse>
suspend fun uploadBook(file: MultipartBody.Part, bookType: RequestBody): Result<UploadBookResponse>
}

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)
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
/**
* Adds https:// if the string has no http/https scheme.
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
*/
fun String.ensureUriScheme(): String {
val trimmed = trim()
return if (trimmed.hasUriScheme()) trimmed else "https://$trimmed"
if (trimmed.hasUriScheme()) return trimmed
val hostPart = trimmed.substringBefore("/")
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
return "$scheme$trimmed"
}
/**

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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.ServerStatusMonitor
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
@ -30,145 +16,26 @@ import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import javax.inject.Inject
data class RemoteLibraryState(
val libraries: List<RemoteLibrary> = emptyList(),
val books: List<Book> = emptyList(),
val selectedLibrary: RemoteLibrary? = null,
val isLoading: Boolean = false,
val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
val offlineOnly: Boolean = false,
val cachingBookId: String? = null,
)
@HiltViewModel
class RemoteLibraryModel
@Inject
constructor(
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
private val fetchBooksUseCase: FetchBooksUseCase,
private val cacheBookUseCase: CacheBookUseCase,
private val deleteCacheUseCase: DeleteCacheUseCase,
private val getCacheStatusUseCase: GetCacheStatusUseCase,
private val serverSettings: ServerSettings,
) : ViewModel() {
private val _state = MutableStateFlow(RemoteLibraryState())
val state = _state.asStateFlow()
private var cacheStatusJob: Job? = null
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
val effects = _effects.asSharedFlow()
init {
loadLibraries()
}
fun loadLibraries() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
if (!serverSettings.hasCredentials()) {
_state.update { it.copy(isLoading = false, error = "Server not configured") }
return@launch
}
val result = fetchLibrariesUseCase()
_state.update {
it.copy(
isLoading = false,
libraries = result.getOrDefault(emptyList()),
error = result.exceptionOrNull()?.message,
)
}
}
}
fun selectLibrary(library: RemoteLibrary) {
_state.update { it.copy(selectedLibrary = library, books = emptyList(), error = null) }
loadBooks(library.id)
}
fun loadBooks(libraryId: String) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
val result = fetchBooksUseCase(libraryId)
_state.update {
it.copy(
isLoading = false,
books = result.getOrDefault(emptyList()),
error = result.exceptionOrNull()?.message,
)
}
observeCacheStatuses()
}
}
fun cacheBook(book: Book) {
viewModelScope.launch {
_state.update { it.copy(cachingBookId = book.remoteId) }
val result = cacheBookUseCase(book)
_state.update { it.copy(cachingBookId = null) }
if (result.isFailure) {
_state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
}
}
observeCacheStatuses()
}
}
fun deleteCache(book: Book) {
viewModelScope.launch {
val result = deleteCacheUseCase(book.remoteId)
if (result.isFailure) {
_state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
}
}
}
}
fun toggleOfflineOnly() {
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
}
fun openBook(book: Book) {
if (!book.hasEbook || book.remoteId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(cachingBookId = book.remoteId) }
val result = withContext(Dispatchers.IO) {
cacheBookUseCase(book)
}
_state.update { it.copy(cachingBookId = null) }
result
.onSuccess { bookId ->
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
}
.onFailure { error ->
_state.update { it.copy(error = error.message ?: "Could not open book") }
}
}
}
private fun observeCacheStatuses() {
cacheStatusJob?.cancel()
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
if (remoteIds.isEmpty()) {
_state.update { it.copy(cacheStatuses = emptyMap()) }
return
}
cacheStatusJob =
viewModelScope.launch {
combine(
remoteIds.map { remoteId ->
getCacheStatusUseCase(remoteId)
},
) { statuses ->
statuses.toList().associateBy { it.remoteId }
}.collect { statuses ->
_state.update { it.copy(cacheStatuses = statuses) }
}
}
}
fetchLibrariesUseCase: FetchLibrariesUseCase,
fetchBooksUseCase: FetchBooksUseCase,
cacheBookUseCase: CacheBookUseCase,
deleteCacheUseCase: DeleteCacheUseCase,
getCacheStatusUseCase: GetCacheStatusUseCase,
serverSettings: ServerSettings,
serverStatusMonitor: ServerStatusMonitor,
) : RemoteLibraryViewModel(
fetchLibrariesUseCase,
fetchBooksUseCase,
cacheBookUseCase,
deleteCacheUseCase,
getCacheStatusUseCase,
serverSettings,
serverStatusMonitor,
) {
override val libraryNameFilter: String? = null
override val itemTypeFilter: String? = null
}

View file

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

View file

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

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
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import androidx.lifecycle.ViewModel
@ -15,40 +14,88 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.LoadControl
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.exoplayer.upstream.DefaultAllocator
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
import org.dueattendant149.bookshelf.data.playback.AudioPlaybackService
import org.dueattendant149.bookshelf.data.playback.di.PlaybackAuthProvider
import org.dueattendant149.bookshelf.data.remote.AuthorizationInterceptor
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.model.player.AudioTrack
import org.dueattendant149.bookshelf.domain.repository.AudiobookshelfRepository
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
import org.dueattendant149.bookshelf.domain.util.fixUriScheme
import java.io.File
import javax.inject.Inject
@HiltViewModel
class PlayerModel
@OptIn(UnstableApi::class)
@Inject
constructor(
@ApplicationContext private val context: Context,
private val exoPlayer: ExoPlayer,
private val serverSettings: ServerSettings,
private val audiobookshelfRepository: AudiobookshelfRepository,
private val authProvider: PlaybackAuthProvider,
private val database: BookDatabase,
private val bookMapper: BookMapper,
private val syncPlaybackProgressUseCase: SyncPlaybackProgressUseCase,
private val cacheBookUseCase: CacheBookUseCase,
private val okHttpClient: okhttp3.OkHttpClient,
private val authProvider: PlaybackAuthProvider,
private val simpleCache: SimpleCache,
) : ViewModel() {
@OptIn(UnstableApi::class)
val exoPlayer: ExoPlayer by lazy {
val authClient = okHttpClient.newBuilder()
.addInterceptor(AuthorizationInterceptor(authProvider::token))
.build()
val upstreamFactory = OkHttpDataSource.Factory(authClient)
val cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(simpleCache)
.setUpstreamDataSourceFactory(upstreamFactory)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
ExoPlayer.Builder(context)
.setRenderersFactory(DefaultRenderersFactory(context).setEnableDecoderFallback(true))
.setTrackSelector(DefaultTrackSelector(context))
.setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
.setLoadControl(createLoadControl())
.build()
.also { it.addListener(listener) }
}
private fun createLoadControl(): LoadControl {
return androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setAllocator(DefaultAllocator(true, 16 * 1024))
.setBufferDurationsMs(
/* minBufferMs = */ 30_000,
/* maxBufferMs = */ 120_000,
/* bufferForPlaybackMs = */ 2_500,
/* bufferForPlaybackAfterRebufferMs = */ 5_000,
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
}
private val _state = MutableStateFlow(PlayerState())
val state = _state.asStateFlow()
@ -97,24 +144,22 @@ class PlayerModel
}
init {
exoPlayer.addListener(listener)
startProgressUpdates()
}
fun init(book: Book) {
if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank()) return
fun init(book: Book, startTrackIndex: Int = 0) {
if (_state.value.book.remoteId == book.remoteId && book.remoteId.isNotBlank() && startTrackIndex == 0) return
_state.update { it.copy(book = book, isLoading = true, error = null) }
viewModelScope.launch {
val url = serverSettings.getAbsUrl()?.fixUriScheme()
val token = serverSettings.getAbsToken()
if (url.isNullOrBlank() || token.isNullOrBlank()) {
_state.update { it.copy(isLoading = false, error = "Audiobookshelf not configured") }
authProvider.refresh(serverSettings)
val baseUrl = serverSettings.getBookshelfUrl()?.fixUriScheme()
if (baseUrl.isNullOrBlank()) {
_state.update { it.copy(isLoading = false, error = "Bookshelf API not configured") }
return@launch
}
authProvider.token = token
val tracksResult = audiobookshelfRepository.getAudioTracks(book.remoteId)
tracksResult.fold(
onSuccess = { tracks ->
@ -123,7 +168,9 @@ class PlayerModel
return@fold
}
_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)
},
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 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()
.setMediaId(track.fileId)
.setUri(uri)
@ -149,7 +207,7 @@ class PlayerModel
.build()
}
exoPlayer.setMediaItems(mediaItems)
exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
exoPlayer.prepare()
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() {
exoPlayer.removeListener(listener)
exoPlayer.stop()

View file

@ -17,14 +17,17 @@ import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.ui.player.PlayerContent
@Parcelize
data class PlayerScreen(val book: Book) : Screen, Parcelable {
data class PlayerScreen(
val book: Book,
val startTrackIndex: Int = 0,
) : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<PlayerModel>()
LaunchedEffect(book) {
model.init(book)
LaunchedEffect(book, startTrackIndex) {
model.init(book, startTrackIndex)
}
DisposableEffect(Unit) {
@ -33,6 +36,7 @@ data class PlayerScreen(val book: Book) : Screen, Parcelable {
}
}
val navigator = org.dueattendant149.bookshelf.ui.navigator.LocalNavigator.current
PlayerContent(
state = model.state,
onPlayPause = model::playPause,
@ -43,6 +47,8 @@ data class PlayerScreen(val book: Book) : Screen, Parcelable {
onSkipPrevious = model::skipToPrevious,
onSetSpeed = model::setSpeed,
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 isPlaying: Boolean = false,
val isLoading: Boolean = false,
val isCaching: Boolean = false,
val cacheProgress: Float = 0f,
val currentPositionMs: Long = 0L,
val durationMs: Long = 0L,
val bufferedPositionMs: Long = 0L,

View file

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

View file

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

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.UpdateBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
import org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
@ -53,6 +54,7 @@ class ReaderModel @Inject constructor(
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
private val getChapterProgressUseCase: GetChapterProgressUseCase,
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
private val paginateReaderTextUseCase: PaginateReaderTextUseCase,
) : ViewModel() {
private val mutex = Mutex()
@ -96,7 +98,9 @@ class ReaderModel @Inject constructor(
book = it.book.copy(
lastOpened = lastOpened
),
text = text
text = text,
pages = emptyList(),
currentPage = 0
)
}
ensureActive()
@ -111,11 +115,20 @@ class ReaderModel @Inject constructor(
}
is ReaderEvent.OnRestoreScroll -> {
snapshotFlow { _state.value.listState.layoutInfo.totalItemsCount }.first { it > 0 }
val state = _state.value
if (state.pages.isNotEmpty()) {
val page = state.pages
.indexOfFirst { it.startTextIndex <= state.book.scrollIndex && state.book.scrollIndex < it.endTextIndex }
.takeIf { it >= 0 }
?: 0
onEvent(ReaderEvent.OnChangePage(page))
_state.update { it.copy(isLoading = false, errorMessage = null) }
} else {
snapshotFlow { state.listState.layoutInfo.totalItemsCount }.first { it > 0 }
_state.value.listState.requestScrollToItem(
index = _state.value.book.scrollIndex,
scrollOffset = _state.value.book.scrollOffset
state.listState.requestScrollToItem(
index = state.book.scrollIndex,
scrollOffset = state.book.scrollOffset
)
_state.update {
@ -131,6 +144,7 @@ class ReaderModel @Inject constructor(
)
}
}
}
is ReaderEvent.OnMenuVisibility -> {
withContext(Dispatchers.Default) {
@ -230,16 +244,71 @@ class ReaderModel @Inject constructor(
scrollJob = viewModelScope.launch(Dispatchers.IO) {
delay(300)
val scrollTo = (_state.value.text.lastIndex * event.progress).roundToInt()
_state.value.listState.requestScrollToItem(
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))
}
}
}
is ReaderEvent.OnChangePage -> {
withContext(Dispatchers.Default) {
val state = _state.value
val pages = state.pages
if (pages.isEmpty()) return@withContext
val page = event.page.coerceIn(0, pages.lastIndex)
val pageStart = pages.getOrNull(page)?.startTextIndex ?: 0
val (currentChapter, currentChapterProgress) = getChapterProgressUseCase(
pageStart,
state.text
)
_state.update {
it.copy(
currentPage = page,
currentChapter = currentChapter,
currentChapterProgress = currentChapterProgress,
book = it.book.copy(
progress = calculateProgress(page),
scrollIndex = pageStart,
scrollOffset = 0
)
)
}
updateBookUseCase(_state.value.book)
syncReadingProgressUseCase(
_state.value.book,
_state.value.currentChapter?.title,
)
}
}
is ReaderEvent.OnPagesComputed -> {
withContext(Dispatchers.Default) {
val pages = event.pages
if (pages.isEmpty()) {
_state.update { it.copy(pages = emptyList(), currentPage = 0) }
return@withContext
}
// Restore the page that contains the saved scrollIndex.
val savedIndex = _state.value.book.scrollIndex
val targetPage = pages
.indexOfFirst { it.startTextIndex <= savedIndex && savedIndex < it.endTextIndex }
.takeIf { it >= 0 }
?: 0
_state.update { it.copy(pages = pages, currentPage = targetPage) }
}
}
is ReaderEvent.OnRestoreCheckpoint -> {
withContext(Dispatchers.Default) {
@ -384,6 +453,10 @@ class ReaderModel @Inject constructor(
is ReaderEvent.OnNavigateToBookInfo -> {
_effects.emit(ReaderEffect.OnNavigateToBookInfo(event.changePath))
}
is ReaderEvent.OnNavigateToRsvp -> {
_effects.emit(ReaderEffect.OnNavigateToRsvp(event.bookId))
}
}
}.also { eventStack.add(it) }
}
@ -484,24 +557,33 @@ class ReaderModel @Inject constructor(
} ?: (-1 to -1)
}
private fun calculateProgress(firstVisibleItemIndex: Int? = null): Float {
private fun calculateProgress(pageOrIndex: Int? = null): Float {
val state = _state.value
if (
_state.value.isLoading ||
_state.value.listState.layoutInfo.totalItemsCount == 0 ||
_state.value.text.isEmpty() ||
_state.value.errorMessage != null
) return _state.value.book.progress
state.isLoading ||
state.text.isEmpty() ||
state.errorMessage != null
) return state.book.progress
if ((firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex) == 0) return 0f
val lastVisibleItemIndex = _state.value.listState.layoutInfo.visibleItemsInfo.last().index
if (lastVisibleItemIndex >= _state.value.text.lastIndex) return 1f
return (firstVisibleItemIndex ?: _state.value.listState.firstVisibleItemIndex)
.div(_state.value.text.lastIndex.toFloat())
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()
}
if (state.listState.layoutInfo.totalItemsCount == 0) return state.book.progress
val index = pageOrIndex ?: state.listState.firstVisibleItemIndex
if (index == 0) return 0f
val lastVisibleItemIndex = state.listState.layoutInfo.visibleItemsInfo.last().index
if (lastVisibleItemIndex >= state.text.lastIndex) return 1f
return index.div(state.text.lastIndex.toFloat()).coerceAndPreventNaN()
}
private suspend inline fun <T> MutableStateFlow<T>.update(function: (T) -> T) {
mutex.withLock {
coroutineContext.ensureActive()

View file

@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.presentation.reader
import android.content.pm.ActivityInfo
import android.os.Parcelable
import android.view.KeyEvent
import android.view.WindowManager
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@ -23,19 +24,29 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Box
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
@ -55,6 +66,7 @@ import org.dueattendant149.bookshelf.ui.common.helpers.LocalSettings
import org.dueattendant149.bookshelf.ui.common.helpers.setBrightness
import org.dueattendant149.bookshelf.ui.reader.ReaderContent
import org.dueattendant149.bookshelf.ui.reader.ReaderEffects
import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
import kotlin.math.roundToInt
@Parcelize
@ -83,6 +95,11 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
) {
state.value.listState
}
val configuration = LocalConfiguration.current
val textMeasurer = rememberTextMeasurer()
val readerPagination = settings.readerPagination.value
val nestedScrollConnection = remember {
derivedStateOf {
object : NestedScrollConnection {
@ -155,76 +172,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
) return@remember 0.sp
(settings.paragraphIndentation.lastValue * 6).sp
}
val perceptionExpanderPadding = remember(
sidePadding,
settings.perceptionExpanderPadding.value
) {
sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
}
val perceptionExpanderThickness = remember(
settings.perceptionExpanderThickness.value
) {
(settings.perceptionExpanderThickness.lastValue * 0.25f).dp
}
val horizontalLimiterHeight = remember(
settings.horizontalLimiterHeight.value
) {
(settings.horizontalLimiterHeight.lastValue * 20).dp
}
val horizontalLimiterRulerThickness = remember(
settings.horizontalLimiterRulerThickness.value
) {
(settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
}
val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
(36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
}
val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
when (settings.highlightedReadingThickness.lastValue) {
2 -> FontWeight.SemiBold
3 -> FontWeight.Bold
else -> FontWeight.Medium
}
}
val horizontalAlignment = remember(settings.textAlignment.value) {
when (settings.textAlignment.lastValue) {
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
ReaderTextAlignment.END -> Alignment.End
}
}
val imagesWidth = remember(settings.imagesWidth.value) {
settings.imagesWidth.lastValue.coerceAtLeast(0.01f)
}
val imagesCornersRoundness = remember(
settings.imagesCornersRoundness.value,
settings.imagesWidth.value
) {
(settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
}
val imagesColorEffects = remember(
settings.imagesColorEffects.value,
fontColor.value,
backgroundColor.value
) {
when (settings.imagesColorEffects.lastValue) {
ReaderColorEffects.OFF -> null
ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
ColorMatrix().apply { setToSaturation(0f) }
)
ReaderColorEffects.FONT -> ColorFilter.tint(
color = fontColor.value,
blendMode = BlendMode.Color
)
ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
color = backgroundColor.value,
blendMode = BlendMode.Color
)
}
}
val progressBarPadding = remember(settings.progressBarPadding.value) {
(settings.progressBarPadding.lastValue * 3).dp
}
@ -287,6 +237,158 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
}
)
}
// Recompute pages when text or relevant settings change.
val pages by produceState(
initialValue = emptyList<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>(),
state.value.text,
readerPagination,
settings.fontSize.value,
settings.lineHeight.value,
settings.sidePadding.value,
settings.paragraphHeight.value,
settings.textAlignment.value,
settings.fontFamily.value,
settings.fontThickness.value,
settings.italic.value,
settings.letterSpacing.value,
settings.paragraphIndentation.value,
settings.progressBarPadding.value,
settings.progressBarFontSize.value,
settings.imagesWidth.value,
settings.fullscreen.value,
settings.cutoutPadding.value,
configuration.screenWidthDp,
configuration.screenHeightDp,
) {
value = withContext(Dispatchers.Default) {
if (!readerPagination || state.value.text.isEmpty()) {
emptyList()
} else {
val contentPaddingTopPx = with(density) {
contentPadding.calculateTopPadding().toPx()
}.toInt()
val contentPaddingBottomPx = with(density) {
contentPadding.calculateBottomPadding().toPx()
}.toInt()
val contentPaddingStartPx = with(density) {
contentPadding.calculateStartPadding(layoutDirection).toPx()
}.toInt()
val contentPaddingEndPx = with(density) {
contentPadding.calculateEndPadding(layoutDirection).toPx()
}.toInt()
val sidePaddingPx = with(density) { sidePadding.toPx() }.toInt()
val verticalPaddingPx = with(density) { verticalPadding.toPx() }.toInt()
val paragraphHeightPx = with(density) { paragraphHeight.toPx() }.toInt()
val contentWidthPx = with(density) {
configuration.screenWidthDp.dp.toPx()
}.toInt() - contentPaddingStartPx - contentPaddingEndPx - sidePaddingPx * 2
val progressBarHeightPx = with(density) {
(progressBarFontSize.toPx() * 1.5f + progressBarPadding.toPx() * 2).toInt()
}
val contentHeightPx = with(density) {
configuration.screenHeightDp.dp.toPx()
}.toInt() - contentPaddingTopPx - contentPaddingBottomPx - verticalPaddingPx * 2 - paragraphHeightPx * 2 - progressBarHeightPx
val imageMaxWidthPx = (contentWidthPx * imagesWidth).toInt()
val style = readerParagraphTextStyle(
fontFamily = settings.fontFamily.lastValue,
fontThickness = settings.fontThickness.lastValue,
fontStyle = if (settings.italic.lastValue) FontStyle.Italic else FontStyle.Normal,
textAlignment = settings.textAlignment.lastValue,
fontSize = settings.fontSize.lastValue.sp,
lineHeight = (settings.fontSize.lastValue + settings.lineHeight.lastValue).sp,
letterSpacing = (settings.letterSpacing.lastValue / 100f).em,
paragraphIndentation = paragraphIndentation,
)
org.dueattendant149.bookshelf.domain.use_case.reader.PaginateReaderTextUseCase().invoke(
text = state.value.text,
contentWidthPx = contentWidthPx,
contentHeightPx = contentHeightPx,
paragraphStyle = style,
chapterTitleAlignment = settings.chapterTitleAlignment.lastValue,
paragraphSpacingPx = with(density) { paragraphHeight.toPx() }.toInt(),
textMeasurer = textMeasurer,
imageMaxWidthPx = imageMaxWidthPx,
chapterExtraHeightPx = with(density) { 55.dp.toPx() }.toInt(),
separatorHeightPx = with(density) { 3.dp.toPx() }.toInt(),
)
}
}
}
val perceptionExpanderPadding = remember(
sidePadding,
settings.perceptionExpanderPadding.value
) {
sidePadding + (settings.perceptionExpanderPadding.lastValue * 8).dp
}
val perceptionExpanderThickness = remember(
settings.perceptionExpanderThickness.value
) {
(settings.perceptionExpanderThickness.lastValue * 0.25f).dp
}
val horizontalLimiterHeight = remember(
settings.horizontalLimiterHeight.value
) {
(settings.horizontalLimiterHeight.lastValue * 20).dp
}
val horizontalLimiterRulerThickness = remember(
settings.horizontalLimiterRulerThickness.value
) {
(settings.horizontalLimiterRulerThickness.lastValue * 0.25f).dp
}
val horizontalGestureSensitivity = remember(settings.horizontalGestureSensitivity.value) {
(36f + settings.horizontalGestureSensitivity.lastValue * (4f - 36f)).dp
}
val highlightedReadingThickness = remember(settings.highlightedReadingThickness.value) {
when (settings.highlightedReadingThickness.lastValue) {
2 -> FontWeight.SemiBold
3 -> FontWeight.Bold
else -> FontWeight.Medium
}
}
val horizontalAlignment = remember(settings.textAlignment.value) {
when (settings.textAlignment.lastValue) {
ReaderTextAlignment.START, ReaderTextAlignment.JUSTIFY -> Alignment.Start
ReaderTextAlignment.CENTER -> Alignment.CenterHorizontally
ReaderTextAlignment.END -> Alignment.End
}
}
val imagesCornersRoundness = remember(
settings.imagesCornersRoundness.value,
settings.imagesWidth.value
) {
(settings.imagesCornersRoundness.lastValue * 3 * imagesWidth).dp
}
val imagesColorEffects = remember(
settings.imagesColorEffects.value,
fontColor.value,
backgroundColor.value
) {
when (settings.imagesColorEffects.lastValue) {
ReaderColorEffects.OFF -> null
ReaderColorEffects.GRAYSCALE -> ColorFilter.colorMatrix(
ColorMatrix().apply { setToSaturation(0f) }
)
ReaderColorEffects.FONT -> ColorFilter.tint(
color = fontColor.value,
blendMode = BlendMode.Color
)
ReaderColorEffects.BACKGROUND -> ColorFilter.tint(
color = backgroundColor.value,
blendMode = BlendMode.Color
)
}
}
val bottomBarPadding = remember(settings.bottomBarPadding.value) {
(settings.bottomBarPadding.lastValue * 4f).dp
}
@ -336,6 +438,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
LaunchedEffect(Unit) {
screenModel.init(bookId = bookId)
}
LaunchedEffect(pages) {
screenModel.onEvent(ReaderEvent.OnPagesComputed(pages))
}
LaunchedEffect(settings.fullscreen.value) {
screenModel.onEvent(
ReaderEvent.OnMenuVisibility(
@ -372,11 +477,45 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
true -> activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
false -> activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
DisposableEffect(readerPagination, state.value.pages, state.value.currentPage) {
if (!readerPagination || state.value.pages.isEmpty()) {
onDispose { }
} else {
val pages = state.value.pages
val currentRef = java.util.concurrent.atomic.AtomicInteger(state.value.currentPage)
val originalCallback = activity.window.callback
val proxy = org.dueattendant149.bookshelf.ui.reader.VolumeKeyWindowCallback(
delegate = originalCallback,
onKeyEvent = { event ->
if (event.action == KeyEvent.ACTION_DOWN) {
val current = currentRef.get()
val target = when (event.keyCode) {
KeyEvent.KEYCODE_VOLUME_UP -> if (current > 0) current - 1 else -1
KeyEvent.KEYCODE_VOLUME_DOWN -> if (current < pages.lastIndex) current + 1 else -1
else -> -1
}
if (target >= 0) {
currentRef.set(target)
screenModel.onEvent(ReaderEvent.OnChangePage(target))
return@VolumeKeyWindowCallback true
}
}
false
}
)
activity.window.callback = proxy
onDispose {
activity.window.callback = originalCallback
}
}
}
DisposableEffect(Unit) {
onDispose {
screenModel.clearAsync()
@ -393,6 +532,10 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
fullscreen = settings.fullscreen.value
)
Box(
modifier = Modifier
.focusable()
) {
ReaderContent(
book = state.value.book,
text = state.value.text,
@ -417,6 +560,14 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
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,
@ -470,7 +621,9 @@ data class ReaderScreen(val bookId: Int) : Screen, Parcelable {
showChaptersDrawer = screenModel::onEvent,
dismissDrawer = screenModel::onEvent,
navigateBack = screenModel::onEvent,
navigateToBookInfo = screenModel::onEvent
navigateToBookInfo = screenModel::onEvent,
navigateToRsvp = screenModel::onEvent
)
}
}
}

View file

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

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

View file

@ -8,36 +8,25 @@ package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.CloudQueue
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@ -51,39 +40,62 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookshelf.R
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryViewModel
import org.dueattendant149.bookshelf.ui.common.components.book.BookCard
import org.dueattendant149.bookshelf.ui.common.components.book.PrimaryActionButton
import org.dueattendant149.bookshelf.ui.common.components.book.SecondaryActionButton
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
import org.dueattendant149.bookshelf.presentation.player.ChapterListScreen
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun RemoteLibraryContent(
model: RemoteLibraryModel,
model: RemoteLibraryViewModel,
titleRes: Int,
onPlayAudio: (Book) -> Unit,
navigateToTts: (bookRemoteId: String) -> Unit,
) {
val state by model.state.collectAsStateWithLifecycle()
val navigator = LocalNavigator.current
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = model::loadLibraries
)
val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
if (!state.offlineOnly) state.books
else state.books.filter { book ->
val status = state.cacheStatuses[book.remoteId]
status?.state == CacheState.COMPLETED ||
status?.state == CacheState.DOWNLOADING ||
status?.state == CacheState.PENDING
val sortedBooks = remember(state.books) {
state.books.sortedByDescending { it.lastOpened ?: 0L }
}
val visibleBooks = remember(sortedBooks, state.offlineOnly, state.cacheStatuses) {
if (!state.offlineOnly) sortedBooks
else sortedBooks.filter { book ->
when (state.cacheStatuses[book.remoteId]?.state) {
CacheState.COMPLETED,
CacheState.DOWNLOADING,
CacheState.PENDING -> true
else -> false
}
}
}
val onlineIndicator = remember(state.serverOnline) {
if (state.serverOnline) ""
else " • Offline"
}
Scaffold(
contentWindowInsets = WindowInsets(0),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.bookshelf_screen)) }
title = {
Text(
text = stringResource(titleRes) + onlineIndicator,
style = MaterialTheme.typography.titleLarge
)
}
)
}
) { padding ->
@ -98,97 +110,71 @@ fun RemoteLibraryContent(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Text(
text = stringResource(R.string.bookshelf_libraries_header),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
}
items(state.libraries.size) { index ->
val library = state.libraries[index]
val selected = state.selectedLibrary?.id == library.id
Card(
onClick = { model.selectLibrary(library) },
modifier = Modifier.fillMaxWidth(),
) {
ListItem(
headlineContent = {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
text = stringResource(
R.string.bookshelf_library_subtitle,
library.mediaType,
library.itemCount
)
)
},
trailingContent = {
if (selected) {
Text(
text = stringResource(R.string.bookshelf_selected_label),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
)
}
}
state.selectedLibrary?.let { library ->
item {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.bookshelf_books_header, library.name),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.bookshelf_offline_only_label),
style = MaterialTheme.typography.bodyMedium
)
Switch(
checked = state.offlineOnly,
onCheckedChange = { model.toggleOfflineOnly() }
)
}
}
}
items(visibleBooks.size) { index ->
val book = visibleBooks[index]
items(
items = visibleBooks,
key = { it.remoteId }
) { book ->
val cacheStatus = state.cacheStatuses[book.remoteId]
val isCaching = state.cachingBookId == book.remoteId
BookListItem(
val progress = when {
book.hasAudio -> {
val dur = book.audioDuration.coerceAtLeast(1L)
(book.audioCurrentPosition.toFloat() / dur).coerceIn(0f, 1f)
}
else -> book.progress.coerceIn(0f, 1f)
}
val primaryLabel: String
val secondaryLabel: String
val onPrimaryClick: () -> Unit
val onSecondaryClick: () -> Unit
when {
book.hasAudio -> {
primaryLabel = stringResource(R.string.bookshelf_play_audio_action)
secondaryLabel = stringResource(R.string.chapters_button)
onPrimaryClick = { onPlayAudio(book) }
onSecondaryClick = { navigator.push(ChapterListScreen(book)) }
}
book.hasEbook -> {
primaryLabel = stringResource(R.string.read)
secondaryLabel = stringResource(R.string.tts_button)
onPrimaryClick = { model.openBook(book) }
onSecondaryClick = { navigateToTts(book.remoteId) }
}
else -> {
primaryLabel = ""
secondaryLabel = ""
onPrimaryClick = {}
onSecondaryClick = {}
}
}
BookCard(
book = book,
cacheStatus = cacheStatus,
isCaching = isCaching,
onDownload = { model.cacheBook(book) },
onDelete = { model.deleteCache(book) },
onRead = if (book.hasEbook) ({ model.openBook(book) }) else null,
onPlayAudio = if (book.hasAudio) ({ onPlayAudio(book) }) else null,
onTts = { navigateToTts(book.remoteId) }
progress = progress,
primaryButton = {
if (primaryLabel.isNotBlank()) {
PrimaryActionButton(
label = primaryLabel,
enabled = !isCaching,
onClick = onPrimaryClick
)
}
},
secondaryButton = {
if (secondaryLabel.isNotBlank()) {
SecondaryActionButton(
label = secondaryLabel,
onClick = onSecondaryClick
)
}
}
)
}
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
if (state.isLoading && state.books.isEmpty()) {
item {
Box(
modifier = Modifier
@ -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(
modifier = Modifier.align(Alignment.Center),
message = stringResource(R.string.bookshelf_empty_message),
@ -212,6 +198,7 @@ fun RemoteLibraryContent(
)
}
state.error?.let { error ->
ErrorPlaceholder(
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.SkipNext
@ -28,6 +29,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
@ -63,6 +65,8 @@ fun PlayerContent(
onSkipPrevious: () -> Unit,
onSetSpeed: (Float) -> Unit,
onSelectTrack: (Int) -> Unit,
onOpenChapters: () -> Unit,
onDownloadAudiobook: () -> Unit,
) {
val playerState by state.collectAsStateWithLifecycle()
val navigator = LocalNavigator.current
@ -121,9 +125,10 @@ fun PlayerContent(
Spacer(modifier = Modifier.height(8.dp))
val duration = playerState.durationMs.coerceAtLeast(1L)
var sliderPosition by remember(playerState.currentPositionMs) {
mutableFloatStateOf(playerState.currentPositionMs / duration.toFloat())
val positionFraction = remember(playerState.currentPositionMs, duration) {
(playerState.currentPositionMs / duration.toFloat()).coerceIn(0f, 1f)
}
var sliderPosition by remember(positionFraction) { mutableFloatStateOf(positionFraction) }
Slider(
value = sliderPosition,
onValueChange = { sliderPosition = it },
@ -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))
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
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
Card(
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.theme.HorizontalExpandingTransition
import org.dueattendant149.bookshelf.ui.theme.readerBarsColor
import kotlin.math.roundToInt
@Composable
fun ReaderBottomBar(
@ -60,6 +61,10 @@ fun ReaderBottomBar(
lockMenu: Boolean,
checkpoints: List<Checkpoint>,
bottomBarPadding: Dp,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onChangePage: (Int) -> Unit,
restoreCheckpoint: (ReaderEvent.OnRestoreCheckpoint) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
@ -142,6 +147,10 @@ fun ReaderBottomBar(
book = book,
lockMenu = lockMenu,
listState = listState,
readerPagination = readerPagination,
pages = pages,
currentPage = currentPage,
onChangePage = onChangePage,
scroll = scroll,
changeProgress = changeProgress
)
@ -240,6 +249,10 @@ private fun ReaderBottomBarSlider(
book: Book,
lockMenu: Boolean,
listState: LazyListState,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onChangePage: (Int) -> Unit,
scroll: (ReaderEvent.OnScroll) -> Unit,
changeProgress: (ReaderEvent.OnChangeProgress) -> Unit
) {
@ -255,7 +268,17 @@ private fun ReaderBottomBarSlider(
value = progress,
enabled = !lockMenu,
onValueChange = {
if (listState.layoutInfo.totalItemsCount > 0) {
if (readerPagination && pages.isNotEmpty()) {
val page = (it * pages.lastIndex).roundToInt().coerceIn(0, pages.lastIndex)
onChangePage(page)
changeProgress(
ReaderEvent.OnChangeProgress(
progress = it,
firstVisibleItemIndex = pages[page].startTextIndex,
firstVisibleItemOffset = 0
)
)
} else if (listState.layoutInfo.totalItemsCount > 0) {
scroll(ReaderEvent.OnScroll(it))
changeProgress(
ReaderEvent.OnChangeProgress(

View file

@ -58,6 +58,10 @@ fun ReaderContent(
checkpoints: List<Checkpoint>,
showMenu: Boolean,
lockMenu: Boolean,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onPageChanged: (Int) -> Unit,
contentPadding: PaddingValues,
verticalPadding: Dp,
horizontalGesture: ReaderHorizontalGesture,
@ -111,6 +115,7 @@ fun ReaderContent(
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
dismissDrawer: (ReaderEvent.OnDismissDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) {
ReaderBottomSheet(
@ -141,6 +146,10 @@ fun ReaderContent(
checkpoints = checkpoints,
showMenu = showMenu,
lockMenu = lockMenu,
readerPagination = readerPagination,
pages = pages,
currentPage = currentPage,
onPageChanged = onPageChanged,
contentPadding = contentPadding,
verticalPadding = verticalPadding,
horizontalGesture = horizontalGesture,
@ -191,7 +200,8 @@ fun ReaderContent(
showSettingsBottomSheet = showSettingsBottomSheet,
showChaptersDrawer = showChaptersDrawer,
navigateBack = navigateBack,
navigateToBookInfo = navigateToBookInfo
navigateToBookInfo = navigateToBookInfo,
navigateToRsvp = navigateToRsvp
)
} else {
ReaderErrorPlaceholder(

View file

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

View file

@ -21,13 +21,18 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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.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.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.coerceAtLeast
@ -51,6 +56,10 @@ import org.dueattendant149.bookshelf.ui.theme.model.HorizontalAlignment
@Composable
fun ReaderLayout(
text: List<ReaderText>,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
readerPagination: Boolean,
onPageChanged: (Int) -> Unit,
listState: LazyListState,
contentPadding: PaddingValues,
verticalPadding: Dp,
@ -97,6 +106,9 @@ fun ReaderLayout(
openDictionary: (ReaderEvent.OnOpenDictionary) -> Unit
) {
val activity = LocalActivity.current
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
SelectionContainer(
onCopyRequested = {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
@ -139,7 +151,7 @@ fun ReaderLayout(
.fillMaxSize()
.background(backgroundColor)
.then(
if (!isLoading && toolbarHidden) {
if (!isLoading && toolbarHidden && !readerPagination) {
Modifier.noRippleClickable(
onClick = {
menuVisibility(
@ -154,7 +166,9 @@ fun ReaderLayout(
)
.padding(contentPadding)
.padding(vertical = verticalPadding)
.readerHorizontalGesture(
.then(
if (!readerPagination) {
Modifier.readerHorizontalGesture(
listState = listState,
horizontalGesture = horizontalGesture,
horizontalGestureScroll = horizontalGestureScroll,
@ -163,7 +177,54 @@ fun ReaderLayout(
horizontalGesturePullAnim = horizontalGesturePullAnim,
isLoading = isLoading
)
} else Modifier
)
) {
if (readerPagination && pages.isNotEmpty()) {
ReaderPaginationLayout(
pages = pages,
currentPage = currentPage,
contentPadding = PaddingValues(0.dp),
verticalPadding = verticalPadding,
highlightedReading = highlightedReading,
highlightedReadingThickness = highlightedReadingThickness,
progress = progress,
progressBar = progressBar,
progressBarPadding = progressBarPadding,
progressBarAlignment = progressBarAlignment,
progressBarFontSize = progressBarFontSize,
paragraphHeight = paragraphHeight,
sidePadding = sidePadding,
bottomBarPadding = 0.dp,
backgroundColor = backgroundColor,
fontColor = fontColor,
images = images,
imagesCaptions = imagesCaptions,
imagesCornersRoundness = imagesCornersRoundness,
imagesAlignment = imagesAlignment,
imagesWidth = imagesWidth,
imagesColorEffects = imagesColorEffects,
fontFamily = fontFamily,
lineHeight = lineHeight,
fontThickness = fontThickness,
fontStyle = fontStyle,
chapterTitleAlignment = chapterTitleAlignment,
textAlignment = textAlignment,
horizontalAlignment = horizontalAlignment,
fontSize = fontSize,
letterSpacing = letterSpacing,
paragraphIndentation = paragraphIndentation,
doubleClickTranslation = doubleClickTranslation,
isLoading = isLoading,
showMenu = showMenu,
menuVisibility = menuVisibility,
openShareApp = openShareApp,
openWebBrowser = openWebBrowser,
openTranslator = openTranslator,
openDictionary = openDictionary,
onPageChanged = onPageChanged
)
} else {
LazyColumnWithScrollbar(
state = listState,
enableScrollbar = false,
@ -241,4 +302,5 @@ fun ReaderLayout(
}
}
}
}
}

View file

@ -6,7 +6,6 @@
package org.dueattendant149.bookshelf.ui.reader
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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
@Composable
fun LazyItemScope.ReaderLayoutText(
fun ReaderLayoutText(
showMenu: Boolean,
entry: ReaderText,
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
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
@Composable
fun LazyItemScope.ReaderLayoutTextChapter(
fun ReaderLayoutTextChapter(
chapter: Chapter,
chapterTitleAlignment: ReaderTextAlignment,
fontColor: Color,
@ -36,10 +35,6 @@ fun LazyItemScope.ReaderLayoutTextChapter(
) {
Column(
Modifier
.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
.fillMaxWidth()
) {
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
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
@Composable
fun LazyItemScope.ReaderLayoutTextImage(
fun ReaderLayoutTextImage(
entry: ReaderText.Image,
sidePadding: Dp,
imagesCornersRoundness: Dp,
@ -32,10 +31,6 @@ fun LazyItemScope.ReaderLayoutTextImage(
) {
Box(
modifier = Modifier
.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
.padding(horizontal = sidePadding)
.fillMaxWidth(),
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.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.TextUnit
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.helpers.noRippleClickable
import org.dueattendant149.bookshelf.ui.reader.model.FontWithName
import org.dueattendant149.bookshelf.ui.reader.readerParagraphTextStyle
@Composable
fun LazyItemScope.ReaderLayoutTextParagraph(
fun ReaderLayoutTextParagraph(
paragraph: Text,
showMenu: Boolean,
fontFamily: FontWithName,
@ -52,9 +49,20 @@ fun LazyItemScope.ReaderLayoutTextParagraph(
openTranslator: (ReaderEvent.OnOpenTranslator) -> 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(
modifier = Modifier
.animateItem(fadeInSpec = null, fadeOutSpec = null)
.fillMaxWidth()
.padding(horizontal = sidePadding),
verticalArrangement = Arrangement.Center,
@ -84,18 +92,7 @@ fun LazyItemScope.ReaderLayoutTextParagraph(
)
} else Modifier
),
style = TextStyle(
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
),
style = style,
highlightText = highlightedReading,
highlightThickness = highlightedReadingThickness
)

View file

@ -7,7 +7,6 @@
package org.dueattendant149.bookshelf.ui.reader
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
@ -18,17 +17,13 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun LazyItemScope.ReaderLayoutTextSeparator(
fun ReaderLayoutTextSeparator(
sidePadding: Dp,
fontColor: Color
) {
HorizontalDivider(
thickness = 3.dp,
modifier = Modifier
.animateItem(
fadeInSpec = null,
fadeOutSpec = null
)
.padding(horizontal = sidePadding)
.clip(CircleShape),
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>,
showMenu: Boolean,
lockMenu: Boolean,
readerPagination: Boolean,
pages: List<org.dueattendant149.bookshelf.domain.model.reader.ReaderPage>,
currentPage: Int,
onPageChanged: (Int) -> Unit,
contentPadding: PaddingValues,
verticalPadding: Dp,
bottomBarPagination: Boolean = readerPagination,
horizontalGesture: ReaderHorizontalGesture,
horizontalGestureScroll: Float,
horizontalGestureSensitivity: Dp,
@ -112,6 +117,7 @@ fun ReaderScaffold(
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) {
Scaffold(
@ -137,7 +143,8 @@ fun ReaderScaffold(
showSettingsBottomSheet = showSettingsBottomSheet,
showChaptersDrawer = showChaptersDrawer,
navigateBack = navigateBack,
navigateToBookInfo = navigateToBookInfo
navigateToBookInfo = navigateToBookInfo,
navigateToRsvp = navigateToRsvp
)
}
},
@ -156,6 +163,10 @@ fun ReaderScaffold(
lockMenu = lockMenu,
checkpoints = checkpoints,
bottomBarPadding = bottomBarPadding,
readerPagination = readerPagination,
pages = pages,
currentPage = currentPage,
onChangePage = onPageChanged,
restoreCheckpoint = restoreCheckpoint,
scroll = scroll,
changeProgress = changeProgress
@ -165,6 +176,10 @@ fun ReaderScaffold(
) {
ReaderLayout(
text = text,
pages = pages,
currentPage = currentPage,
readerPagination = readerPagination,
onPageChanged = onPageChanged,
listState = listState,
contentPadding = contentPadding,
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.material.icons.Icons
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.rounded.Menu
import androidx.compose.material3.ExperimentalMaterial3Api
@ -53,6 +54,7 @@ fun ReaderTopBar(
showSettingsBottomSheet: (ReaderEvent.OnShowSettingsBottomSheet) -> Unit,
showChaptersDrawer: (ReaderEvent.OnShowChaptersDrawer) -> Unit,
navigateToBookInfo: (ReaderEvent.OnNavigateToBookInfo) -> Unit,
navigateToRsvp: (ReaderEvent.OnNavigateToRsvp) -> Unit,
navigateBack: (ReaderEvent.OnNavigateBack) -> Unit
) {
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(
icon = Icons.Default.Settings,
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(
listState: LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
isRoot: Boolean = false,
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
@ -28,6 +29,7 @@ fun SettingsContent(
SettingsScaffold(
listState = listState,
scrollBehavior = scrollBehavior,
isRoot = isRoot,
navigateToGeneralSettings = navigateToGeneralSettings,
navigateToAppearanceSettings = navigateToAppearanceSettings,
navigateToReaderSettings = navigateToReaderSettings,

View file

@ -24,6 +24,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
fun SettingsScaffold(
listState: LazyListState,
scrollBehavior: TopAppBarScrollBehavior,
isRoot: Boolean = false,
navigateToGeneralSettings: () -> Unit,
navigateToAppearanceSettings: () -> Unit,
navigateToReaderSettings: () -> Unit,
@ -42,6 +43,7 @@ fun SettingsScaffold(
topBar = {
SettingsTopBar(
scrollBehavior = scrollBehavior,
isRoot = isRoot,
navigateToStart = navigateToStart,
navigateBack = navigateBack
)

View file

@ -24,6 +24,7 @@ import org.dueattendant149.bookshelf.ui.navigator.NavigatorBackIconButton
@Composable
fun SettingsTopBar(
scrollBehavior: TopAppBarScrollBehavior,
isRoot: Boolean = false,
navigateToStart: () -> Unit,
navigateBack: () -> Unit
) {
@ -32,9 +33,11 @@ fun SettingsTopBar(
StyledText(stringResource(id = R.string.settings_screen))
},
navigationIcon = {
if (!isRoot) {
NavigatorBackIconButton(
navigateBack = navigateBack
)
}
},
actions = {
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.HorizontalGestureScrollOption
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(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
@ -34,6 +35,10 @@ fun LazyListScope.ReadingModeSubcategory(
showTitle = showTitle,
showDivider = showDivider
) {
item {
ReaderPaginationOption()
}
item {
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.PerceptionExpanderPaddingOption
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(
titleColor: @Composable () -> Color = { MaterialTheme.colorScheme.primary },
@ -82,5 +88,29 @@ fun LazyListScope.ReadingSpeedSubcategory(
item {
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="reading_speed_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="chapters_reader_settings">Главы</string>
<string name="images_reader_settings">Изображения</string>
@ -535,6 +537,13 @@
<string name="library_content_desc">Ваша библиотека</string>
<string name="history_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_content_desc">Удалённая книжная полка</string>
<string name="bookshelf_offline_only_label">Только оффлайн</string>
@ -544,7 +553,8 @@
<string name="bookshelf_cache_failed">Ошибка</string>
<string name="bookshelf_download_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="playback_progress_sync_failed">Не удалось синхронизировать прогресс воспроизведения</string>
<string name="arrow_content_desc">Стрелка</string>
@ -580,4 +590,34 @@
<string name="tts_create_job_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>

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.DownloadsListResponse
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.PlaybackProgressResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
@ -102,6 +105,47 @@ class BookshelfApiServiceTest {
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
fun `searchLibrary deserializes correctly`() = runBlocking {
server.enqueue(jsonResponse("""