Merge feature/ebook: open remote ebooks from RemoteLibrary

This commit is contained in:
Atte149 2026-06-16 12:12:01 +03:00
commit c8c1503d8c
12 changed files with 286 additions and 179 deletions

View file

@ -68,8 +68,12 @@
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed. - Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed. - Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed.
- Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed. - Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
- Phase 5: UI scaffold (server settings screen, RemoteLibrary tab) completed. - Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
- Next: audio player (ExoPlayer + MediaSession), TTS integration, offline cache, reader integration for ebooks. - Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged.
- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged.
- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model.
- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`.
- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync.
## WARNs ## WARNs

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

View file

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

View file

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

View file

@ -7,10 +7,12 @@
package org.dueattendant149.bookshelf.data.service package org.dueattendant149.bookshelf.data.service
import android.app.Application import android.app.Application
import android.net.Uri
import org.dueattendant149.bookshelf.data.model.file.CachedFile import org.dueattendant149.bookshelf.data.model.file.CachedFile
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.service.FileProvider import org.dueattendant149.bookshelf.domain.service.FileProvider
import java.io.File
import javax.inject.Inject import javax.inject.Inject
class FileProviderImpl @Inject constructor( class FileProviderImpl @Inject constructor(
@ -18,6 +20,25 @@ class FileProviderImpl @Inject constructor(
) : FileProvider { ) : FileProvider {
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching { 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 -> application.contentResolver.persistedUriPermissions.forEach { storage ->
val storageFile = CachedFileCompat.fromUri( val storageFile = CachedFileCompat.fromUri(
application, application,

View file

@ -11,7 +11,20 @@ import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
interface CacheRepository { interface CacheRepository {
suspend fun cacheBook(book: Book): Result<Unit> /**
* 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>
/**
* Deletes the cached file(s) for the book identified by [remoteId].
*/
suspend fun deleteCache(remoteId: String): Result<Unit> suspend fun deleteCache(remoteId: String): Result<Unit>
/**
* Observes the cache status for the book identified by [remoteId].
*/
fun observeCacheStatus(remoteId: String): Flow<CacheStatus> fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
} }

View file

@ -15,5 +15,8 @@ class CacheBookUseCase
constructor( constructor(
private val repository: CacheRepository, 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,13 +9,16 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.settings.ServerSettings import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.library.Book
@ -35,6 +38,7 @@ data class RemoteLibraryState(
val error: String? = null, val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(), val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
val offlineOnly: Boolean = false, val offlineOnly: Boolean = false,
val cachingBookId: String? = null,
) )
@HiltViewModel @HiltViewModel
@ -52,6 +56,9 @@ class RemoteLibraryModel
val state = _state.asStateFlow() val state = _state.asStateFlow()
private var cacheStatusJob: Job? = null private var cacheStatusJob: Job? = null
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
val effects = _effects.asSharedFlow()
init { init {
loadLibraries() loadLibraries()
} }
@ -96,7 +103,9 @@ class RemoteLibraryModel
fun cacheBook(book: Book) { fun cacheBook(book: Book) {
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(cachingBookId = book.remoteId) }
val result = cacheBookUseCase(book) val result = cacheBookUseCase(book)
_state.update { it.copy(cachingBookId = null) }
if (result.isFailure) { if (result.isFailure) {
_state.update { _state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed") it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
@ -121,6 +130,26 @@ class RemoteLibraryModel
_state.update { it.copy(offlineOnly = !it.offlineOnly) } _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() { private fun observeCacheStatuses() {
cacheStatusJob?.cancel() cacheStatusJob?.cancel()
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() } val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }

View file

@ -8,10 +8,15 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@Parcelize @Parcelize
object RemoteLibraryScreen : Screen, Parcelable { object RemoteLibraryScreen : Screen, Parcelable {
@ -19,6 +24,19 @@ object RemoteLibraryScreen : Screen, Parcelable {
@Composable @Composable
override fun Content() { override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>() 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) RemoteLibraryContent(model = model)
} }
} }

View file

@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -40,6 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
@ -63,6 +65,16 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
onRefresh = model::loadLibraries 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
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -155,14 +167,17 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
} }
} }
items(state.books.size) { index -> items(visibleBooks.size) { index ->
val book = state.books[index] val book = visibleBooks[index]
val cacheStatus = state.cacheStatuses[book.remoteId] val cacheStatus = state.cacheStatuses[book.remoteId]
val isCaching = state.cachingBookId == book.remoteId
BookListItem( BookListItem(
book = book, book = book,
cacheStatus = cacheStatus, cacheStatus = cacheStatus,
isCaching = isCaching,
onDownload = { model.cacheBook(book) }, onDownload = { model.cacheBook(book) },
onDelete = { model.deleteCache(book) } onDelete = { model.deleteCache(book) },
onRead = if (book.hasEbook) ({ model.openBook(book) }) else null
) )
} }
@ -215,8 +230,10 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
private fun BookListItem( private fun BookListItem(
book: Book, book: Book,
cacheStatus: CacheStatus?, cacheStatus: CacheStatus?,
isCaching: Boolean,
onDownload: () -> Unit, onDownload: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onRead: (() -> Unit)?,
) { ) {
val cacheState = cacheStatus?.state ?: CacheState.NONE val cacheState = cacheStatus?.state ?: CacheState.NONE
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
@ -278,6 +295,23 @@ private fun BookListItem(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) )
} }
onRead?.let { read ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = read,
enabled = !isCaching
) {
Text(stringResource(R.string.read))
}
}
}
} }
} }
} }

View file

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