Merge feature/ebook: open remote ebooks from RemoteLibrary
This commit is contained in:
commit
c8c1503d8c
12 changed files with 286 additions and 179 deletions
|
|
@ -68,8 +68,12 @@
|
|||
- 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 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
|
||||
- Phase 5: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
||||
- Next: audio player (ExoPlayer + MediaSession), TTS integration, offline cache, reader integration for ebooks.
|
||||
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
||||
- 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -7,24 +7,17 @@
|
|||
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.withContext
|
||||
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
|
||||
|
|
@ -38,194 +31,159 @@ 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 {
|
||||
|
||||
private val cachedFileDao by lazy { database.cachedFileDao }
|
||||
|
||||
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",
|
||||
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (book.hasAudio) {
|
||||
createCacheRecord(
|
||||
bookId,
|
||||
"audio",
|
||||
"$absUrl/api/items/${book.remoteId}/download",
|
||||
)
|
||||
return@withContext existing.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 url = serverSettings.getAbsUrl()
|
||||
val token = serverSettings.getAbsToken()
|
||||
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
||||
throw IllegalStateException("Audiobookshelf server is not configured.")
|
||||
}
|
||||
|
||||
val request =
|
||||
OneTimeWorkRequestBuilder<CacheDownloadWorker>()
|
||||
.setInputData(inputData)
|
||||
.setConstraints(
|
||||
Constraints
|
||||
.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
val client = apiFactory.provideClient(url, token)
|
||||
?: throw IllegalStateException("Could not create Audiobookshelf client.")
|
||||
|
||||
workManager.enqueueUniqueWork(
|
||||
CacheDownloadWorker.workName(bookId),
|
||||
ExistingWorkPolicy.KEEP,
|
||||
request,
|
||||
val response = client.downloadBook(book.remoteId)
|
||||
if (!response.isSuccessful) {
|
||||
throw IllegalStateException("Download failed: ${response.code()}")
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
?: throw IllegalStateException("Download response body is empty.")
|
||||
|
||||
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
|
||||
val fileName = guessFileName(response, book.remoteId)
|
||||
val file = File(ebooksDir, fileName)
|
||||
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val localBook = if (existing != null) {
|
||||
book.copy(
|
||||
id = existing.id,
|
||||
filePath = file.absolutePath,
|
||||
scrollIndex = existing.scrollIndex,
|
||||
scrollOffset = existing.scrollOffset,
|
||||
progress = existing.progress,
|
||||
categories = existing.categories,
|
||||
)
|
||||
} else {
|
||||
book.copy(filePath = file.absolutePath)
|
||||
}
|
||||
|
||||
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
|
||||
|
||||
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||
?: throw IllegalStateException("Could not insert or update book.")
|
||||
|
||||
database.ebookFileDao.insert(
|
||||
EbookFileEntity(
|
||||
bookId = inserted.id,
|
||||
fileId = book.remoteId,
|
||||
format = file.extension,
|
||||
localPath = file.absolutePath,
|
||||
)
|
||||
)
|
||||
|
||||
cachedFileDao.insert(
|
||||
CachedFileEntity(
|
||||
bookId = inserted.id,
|
||||
type = "ebook",
|
||||
remoteUrl = "$url/api/items/${book.remoteId}/download",
|
||||
localPath = file.absolutePath,
|
||||
status = "completed",
|
||||
progress = 1f,
|
||||
)
|
||||
)
|
||||
|
||||
inserted.id
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id))
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||
?: throw IllegalStateException("Book not found")
|
||||
val ebook = database.ebookFileDao.getByBookId(book.id)
|
||||
val cachedFiles = cachedFileDao.getByBookId(book.id)
|
||||
|
||||
ebook?.localPath?.let { File(it).delete() }
|
||||
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
||||
|
||||
database.ebookFileDao.deleteByBookId(book.id)
|
||||
cachedFileDao.deleteByBookId(book.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(),
|
||||
),
|
||||
)
|
||||
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
|
||||
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||
if (book == null) {
|
||||
emit(CacheStatus(remoteId, CacheState.NONE, 0f, 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 {
|
||||
cachedFileDao.observeByBookId(book.id).collect { entities ->
|
||||
val files = entities.map {
|
||||
CachedFile(
|
||||
type = it.type,
|
||||
status = it.status,
|
||||
progress = it.progress,
|
||||
localPath = it.localPath,
|
||||
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 state = when {
|
||||
entities.isEmpty() -> CacheState.NONE
|
||||
entities.all { it.status == "completed" } -> CacheState.COMPLETED
|
||||
entities.any { it.status == "failed" } -> CacheState.FAILED
|
||||
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
||||
entities.any { it.status == "pending" } -> CacheState.PENDING
|
||||
else -> CacheState.NONE
|
||||
}
|
||||
val progress = if (entities.isEmpty()) {
|
||||
0f
|
||||
} else {
|
||||
entities.map { it.progress }.average().toFloat()
|
||||
}
|
||||
emit(CacheStatus(remoteId, state, progress, files))
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,20 @@ 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>
|
||||
/**
|
||||
* 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>
|
||||
|
||||
/**
|
||||
* Observes the cache status for the book identified by [remoteId].
|
||||
*/
|
||||
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -9,13 +9,16 @@ 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.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
|
||||
|
|
@ -35,6 +38,7 @@ data class RemoteLibraryState(
|
|||
val error: String? = null,
|
||||
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
||||
val offlineOnly: Boolean = false,
|
||||
val cachingBookId: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -52,6 +56,9 @@ class RemoteLibraryModel
|
|||
val state = _state.asStateFlow()
|
||||
private var cacheStatusJob: Job? = null
|
||||
|
||||
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadLibraries()
|
||||
}
|
||||
|
|
@ -96,7 +103,9 @@ class RemoteLibraryModel
|
|||
|
||||
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")
|
||||
|
|
@ -121,6 +130,26 @@ class RemoteLibraryModel
|
|||
_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() }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Download
|
|||
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
|
||||
|
|
@ -40,6 +41,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
|
|
@ -63,6 +65,16 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
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(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
|
@ -155,14 +167,17 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
|||
}
|
||||
}
|
||||
|
||||
items(state.books.size) { index ->
|
||||
val book = state.books[index]
|
||||
items(visibleBooks.size) { index ->
|
||||
val book = visibleBooks[index]
|
||||
val cacheStatus = state.cacheStatuses[book.remoteId]
|
||||
val isCaching = state.cachingBookId == book.remoteId
|
||||
BookListItem(
|
||||
book = book,
|
||||
cacheStatus = cacheStatus,
|
||||
isCaching = isCaching,
|
||||
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(
|
||||
book: Book,
|
||||
cacheStatus: CacheStatus?,
|
||||
isCaching: Boolean,
|
||||
onDownload: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onRead: (() -> Unit)?,
|
||||
) {
|
||||
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
||||
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
||||
|
|
@ -278,6 +295,23 @@ private fun BookListItem(
|
|||
.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue