feat(remote-library): add ebook opening from RemoteLibrary tab

- Add CacheRepository + CacheBookUseCase to download/cache remote ebooks
  via Audiobookshelf and upsert a local BookEntity/EbookFileEntity.
- Support plain local file paths in FileProviderImpl and CachedFile.canAccess
  so reader can open downloaded ebooks stored in app filesDir.
- Add Read button to RemoteLibraryContent for books with hasEbook=true.
- Wire RemoteLibraryModel/Screen to cache the ebook and navigate to
  ReaderScreen with the local book id.
- Add string resource for the Read button.
This commit is contained in:
Atte149 2026-06-16 11:48:30 +03:00
parent ed3afc1f16
commit d1257e1d60
11 changed files with 247 additions and 387 deletions

View file

@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
import org.dueattendant149.bookshelf.domain.repository.BookRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
@ -96,6 +97,12 @@ abstract class RepositoryModule {
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
): RemoteLibraryRepository
@Binds
@Singleton
abstract fun bindCacheRepository(
cacheRepositoryImpl: CacheRepositoryImpl
): CacheRepository
@Binds
@Singleton
abstract fun bindBookMapper(

View file

@ -50,6 +50,11 @@ class CachedFile(
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
fun canAccess(): Boolean {
if (uri.scheme == "file") {
val file = File(uri.path ?: return false)
return file.exists() && file.canRead()
}
return try {
context.contentResolver.query(uri, null, null, null, null)?.let {
it.close()

View file

@ -7,27 +7,14 @@
package org.dueattendant149.bookshelf.data.repository
import android.app.Application
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import android.webkit.URLUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.local.dto.BookEntity
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.settings.ServerSettings
import org.dueattendant149.bookshelf.data.worker.CacheDownloadWorker
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.CacheRepository
import java.io.File
@ -38,194 +25,98 @@ import javax.inject.Singleton
class CacheRepositoryImpl
@Inject
constructor(
private val application: Application,
private val database: BookDatabase,
private val bookMapper: BookMapper,
private val apiFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings,
private val workManager: WorkManager,
private val application: Application,
) : CacheRepository {
override suspend fun cacheBook(book: Book): Result<Unit> = runCatching {
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
withContext(Dispatchers.IO) {
val localBook = findOrInsertBook(book)
val bookId = localBook.id
val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured")
require(book.hasEbook) { "Book has no ebook." }
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
if (book.coverUrl.isNotBlank()) {
createCacheRecord(bookId, "cover", book.coverUrl)
}
if (book.hasEbook) {
createCacheRecord(
bookId,
"ebook",
"$absUrl/api/items/${book.remoteId}/download",
)
}
if (book.hasAudio) {
createCacheRecord(
bookId,
"audio",
"$absUrl/api/items/${book.remoteId}/download",
)
}
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
val inputData =
Data
.Builder()
.putInt(CacheDownloadWorker.KEY_BOOK_ID, bookId)
.putString(CacheDownloadWorker.KEY_REMOTE_ID, book.remoteId)
.putString(CacheDownloadWorker.KEY_TITLE, book.title)
.putString(CacheDownloadWorker.KEY_MEDIA_TYPE, book.mediaType)
.putBoolean(CacheDownloadWorker.KEY_HAS_AUDIO, book.hasAudio)
.putBoolean(CacheDownloadWorker.KEY_HAS_EBOOK, book.hasEbook)
.putString(CacheDownloadWorker.KEY_COVER_URL, book.coverUrl)
.build()
val request =
OneTimeWorkRequestBuilder<CacheDownloadWorker>()
.setInputData(inputData)
.setConstraints(
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
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)
)
)
.build()
workManager.enqueueUniqueWork(
CacheDownloadWorker.workName(bookId),
ExistingWorkPolicy.KEEP,
request,
)
}
}
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val book = database.bookDao.findBookByRemoteId(remoteId) ?: return@withContext
val records = database.cachedFileDao.getByBookId(book.id)
records.forEach { record ->
record.localPath?.let { path ->
File(path).delete()
}
database.cachedFileDao.delete(record)
return@withContext existing.id
}
workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id))
val url = serverSettings.getAbsUrl()
val token = serverSettings.getAbsToken()
if (url.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Audiobookshelf server is not configured.")
}
val client = apiFactory.provideClient(url, token)
?: throw IllegalStateException("Could not create Audiobookshelf client.")
val response = client.downloadBook(book.remoteId)
if (!response.isSuccessful) {
throw IllegalStateException("Download failed: ${response.code()}")
}
val body = response.body()
?: throw IllegalStateException("Download response body is empty.")
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
val fileName = guessFileName(response, book.remoteId)
val file = File(ebooksDir, fileName)
body.byteStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
val localBook = if (existing != null) {
book.copy(
id = existing.id,
filePath = file.absolutePath,
scrollIndex = existing.scrollIndex,
scrollOffset = existing.scrollOffset,
progress = existing.progress,
categories = existing.categories,
)
} else {
book.copy(filePath = file.absolutePath)
}
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
?: throw IllegalStateException("Could not insert or update book.")
database.ebookFileDao.insert(
EbookFileEntity(
bookId = inserted.id,
fileId = book.remoteId,
format = file.extension,
localPath = file.absolutePath,
)
)
inserted.id
}
}
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> =
flow {
val bookId = database.bookDao.findBookByRemoteId(remoteId)?.id
if (bookId == null) {
emit(
CacheStatus(
remoteId = remoteId,
state = CacheState.NONE,
progress = 0f,
cachedFiles = emptyList(),
),
)
return@flow
}
database.cachedFileDao.observeByBookId(bookId).map { records ->
records.toCacheStatus(remoteId)
}.collect { emit(it) }
}.flowOn(Dispatchers.IO)
private suspend fun findOrInsertBook(book: Book): BookEntity {
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
return if (existing != null) {
val updated =
existing.copy(
title = book.title,
author = book.author.getAsString() ?: "",
coverUrl = book.coverUrl,
hasAudio = book.hasAudio,
hasEbook = book.hasEbook,
audioDuration = book.audioDuration,
mediaType = book.mediaType,
libraryId = book.libraryId,
lastSyncedAt = System.currentTimeMillis(),
)
database.bookDao.updateBook(updated)
database.bookDao.findBookByRemoteId(book.remoteId) ?: updated
} else {
val entity = bookMapper.toBookEntity(book.copy(filePath = ""))
database.bookDao.insertBook(entity)
database.bookDao.findBookByRemoteId(book.remoteId)
?: error("Failed to insert book for caching")
}
}
private suspend fun createCacheRecord(
bookId: Int,
type: String,
url: String,
) {
val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type)
if (existing == null) {
database.cachedFileDao.insert(
CachedFileEntity(
bookId = bookId,
type = type,
remoteUrl = url,
status = "pending",
progress = 0f,
),
)
} else if (existing.status == "completed" &&
existing.localPath != null &&
File(existing.localPath).exists()
) {
// Already cached locally, leave it alone.
} else {
database.cachedFileDao.update(
existing.copy(status = "pending", progress = 0f),
)
}
}
private fun List<CachedFileEntity>.toCacheStatus(remoteId: String): CacheStatus {
if (isEmpty()) {
return CacheStatus(
remoteId = remoteId,
state = CacheState.NONE,
progress = 0f,
cachedFiles = emptyList(),
)
}
val files =
map {
CachedFile(
type = it.type,
status = it.status,
progress = it.progress,
localPath = it.localPath,
)
}
val state =
when {
files.any { it.status == "failed" } -> CacheState.FAILED
files.any { it.status == "downloading" } -> CacheState.DOWNLOADING
files.all { it.status == "completed" } -> CacheState.COMPLETED
else -> CacheState.PENDING
}
val progress =
when (state) {
CacheState.DOWNLOADING -> files.filter { it.status == "downloading" }.map { it.progress }.average().toFloat()
CacheState.COMPLETED -> 1f
else -> 0f
}
return CacheStatus(
remoteId = remoteId,
state = state,
progress = progress,
cachedFiles = files,
)
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
val contentDisposition = response.headers()["Content-Disposition"]
val mimeType = response.body()?.contentType()?.toString()
val url = response.raw().request.url.toString()
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
return if (name.contains(".")) name else "$remoteId.epub"
}
}

View file

@ -7,10 +7,12 @@
package org.dueattendant149.bookshelf.data.service
import android.app.Application
import android.net.Uri
import org.dueattendant149.bookshelf.data.model.file.CachedFile
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.service.FileProvider
import java.io.File
import javax.inject.Inject
class FileProviderImpl @Inject constructor(
@ -18,6 +20,25 @@ class FileProviderImpl @Inject constructor(
) : FileProvider {
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching {
if (book.filePath.isBlank()) {
throw NoSuchElementException("Book file path is empty.")
}
val plainFile = File(book.filePath)
if (plainFile.exists() && plainFile.isFile) {
return@runCatching CachedFileCompat.fromUri(
application,
Uri.fromFile(plainFile),
builder = CachedFileCompat.build(
name = plainFile.name,
path = plainFile.absolutePath,
size = plainFile.length(),
lastModified = plainFile.lastModified(),
isDirectory = false
)
)
}
application.contentResolver.persistedUriPermissions.forEach { storage ->
val storageFile = CachedFileCompat.fromUri(
application,

View file

@ -6,12 +6,13 @@
package org.dueattendant149.bookshelf.domain.repository
import kotlinx.coroutines.flow.Flow
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
interface CacheRepository {
suspend fun cacheBook(book: Book): Result<Unit>
suspend fun deleteCache(remoteId: String): Result<Unit>
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
/**
* Ensures the ebook file for [book] is available locally.
*
* @return the local database id of the book (existing or newly inserted).
*/
suspend fun cacheBook(book: Book): Result<Int>
}

View file

@ -15,5 +15,8 @@ class CacheBookUseCase
constructor(
private val repository: CacheRepository,
) {
suspend operator fun invoke(book: Book): Result<Unit> = repository.cacheBook(book)
/**
* Ensures the ebook file for [book] is cached locally and returns the local book id.
*/
suspend operator fun invoke(book: Book): Result<Int> = repository.cacheBook(book)
}

View file

@ -0,0 +1,14 @@
/*
* 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 androidx.compose.runtime.Immutable
@Immutable
sealed class RemoteLibraryEffect {
data class OnNavigateToReader(val bookId: Int) : RemoteLibraryEffect()
}

View file

@ -9,20 +9,18 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
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.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
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
import javax.inject.Inject
@ -33,8 +31,7 @@ data class RemoteLibraryState(
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
@ -44,13 +41,13 @@ class RemoteLibraryModel
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()
@ -90,56 +87,26 @@ class RemoteLibraryModel
error = result.exceptionOrNull()?.message,
)
}
observeCacheStatuses()
}
}
fun cacheBook(book: Book) {
fun openBook(book: Book) {
if (!book.hasEbook || book.remoteId.isBlank()) return
viewModelScope.launch {
val result = cacheBookUseCase(book)
if (result.isFailure) {
_state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
}
_state.update { it.copy(cachingBookId = book.remoteId) }
val result = withContext(Dispatchers.IO) {
cacheBookUseCase(book)
}
observeCacheStatuses()
}
}
_state.update { it.copy(cachingBookId = null) }
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")
result
.onSuccess { bookId ->
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
}
}
}
}
fun toggleOfflineOnly() {
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
}
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) }
.onFailure { error ->
_state.update { it.copy(error = error.message ?: "Could not open book") }
}
}
}
}
}

View file

@ -8,10 +8,15 @@ 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.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@Parcelize
object RemoteLibraryScreen : Screen, Parcelable {
@ -19,6 +24,19 @@ object RemoteLibraryScreen : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>()
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)
}
}

View file

@ -7,189 +7,121 @@
package org.dueattendant149.bookshelf.ui.bookshelf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
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.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.ListItem
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.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
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.presentation.bookshelf.RemoteLibraryModel
import org.dueattendant149.bookshelf.ui.common.components.placeholder.EmptyPlaceholder
import org.dueattendant149.bookshelf.ui.common.components.placeholder.ErrorPlaceholder
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RemoteLibraryContent(model: RemoteLibraryModel) {
val state by model.state.collectAsStateWithLifecycle()
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = model::loadLibraries
)
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.bookshelf_screen)) }
title = { Text("Bookshelf") }
)
}
) { padding ->
Box(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.pullRefresh(refreshState)
.padding(padding)
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
Button(
onClick = model::loadLibraries,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text("Refresh libraries")
}
}
if (state.isLoading) {
item {
CircularProgressIndicator()
}
}
state.error?.let { error ->
item {
Text(
text = stringResource(R.string.bookshelf_libraries_header),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 4.dp)
text = error,
color = MaterialTheme.colorScheme.error,
)
}
}
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
)
}
}
)
}
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(),
) {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
Text(
text = "${library.mediaType}${library.itemCount} items",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
)
}
}
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)
)
}
}
items(state.books.size) { index ->
val book = state.books[index]
Card(
modifier = Modifier.fillMaxWidth(),
) {
ListItem(
headlineContent = {
Text(
text = book.title,
style = MaterialTheme.typography.titleMedium
)
},
supportingContent = {
Text(
text = book.author.getAsString()
?: stringResource(R.string.unknown_author)
)
}
)
}
}
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
item {
Box(
items(state.books.size) { index ->
val book = state.books[index]
Card(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = book.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
Text(
text = book.author.getAsString() ?: "",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp)
)
if (book.hasEbook) {
Button(
onClick = { model.openBook(book) },
enabled = state.cachingBookId != book.remoteId,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
CircularProgressIndicator()
Text(stringResource(R.string.read))
}
}
}
}
if (!state.isLoading && state.libraries.isEmpty() && state.error == null) {
EmptyPlaceholder(
modifier = Modifier.align(Alignment.Center),
message = stringResource(R.string.bookshelf_empty_message),
icon = rememberVectorPainter(Icons.Default.CloudQueue),
actionTitle = stringResource(R.string.bookshelf_refresh_action),
action = model::loadLibraries
)
}
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::loadLibraries
)
}
PullRefreshIndicator(
refreshing = state.isLoading,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = MaterialTheme.colorScheme.inverseSurface,
contentColor = MaterialTheme.colorScheme.inverseOnSurface
)
}
}
}

View file

@ -98,6 +98,7 @@
<string name="web_search">Web search</string>
<string name="share">Share</string>
<string name="ok">OK</string>
<string name="read">Read</string>
<string name="change_path">Change path</string>
<string name="add_folder">Add folder</string>
<string name="edit">Edit</string>