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:
parent
ed3afc1f16
commit
d1257e1d60
11 changed files with 247 additions and 387 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -7,27 +7,14 @@
|
||||||
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.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.dueattendant149.bookshelf.data.local.dto.BookEntity
|
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
|
||||||
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
|
|
||||||
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.CacheStatus
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -38,194 +25,98 @@ 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 {
|
|
||||||
|
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()) {
|
|
||||||
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 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(),
|
|
||||||
)
|
|
||||||
.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)
|
|
||||||
}
|
|
||||||
workManager.cancelUniqueWork(CacheDownloadWorker.workName(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(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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)
|
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||||
return if (existing != null) {
|
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
||||||
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(
|
if (existing != null &&
|
||||||
bookId: Int,
|
cachedEbook?.localPath != null &&
|
||||||
type: String,
|
File(cachedEbook.localPath).exists()
|
||||||
url: String,
|
|
||||||
) {
|
) {
|
||||||
val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type)
|
if (existing.filePath != cachedEbook.localPath) {
|
||||||
if (existing == null) {
|
database.bookDao.updateBook(
|
||||||
database.cachedFileDao.insert(
|
bookMapper.toBookEntity(
|
||||||
CachedFileEntity(
|
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
|
||||||
bookId = bookId,
|
)
|
||||||
type = type,
|
)
|
||||||
remoteUrl = url,
|
}
|
||||||
status = "pending",
|
return@withContext existing.id
|
||||||
progress = 0f,
|
}
|
||||||
),
|
|
||||||
|
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 if (existing.status == "completed" &&
|
|
||||||
existing.localPath != null &&
|
|
||||||
File(existing.localPath).exists()
|
|
||||||
) {
|
|
||||||
// Already cached locally, leave it alone.
|
|
||||||
} else {
|
} else {
|
||||||
database.cachedFileDao.update(
|
book.copy(filePath = file.absolutePath)
|
||||||
existing.copy(status = "pending", progress = 0f),
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun List<CachedFileEntity>.toCacheStatus(remoteId: String): CacheStatus {
|
|
||||||
if (isEmpty()) {
|
|
||||||
return CacheStatus(
|
|
||||||
remoteId = remoteId,
|
|
||||||
state = CacheState.NONE,
|
|
||||||
progress = 0f,
|
|
||||||
cachedFiles = emptyList(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
inserted.id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val files =
|
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
|
||||||
map {
|
val contentDisposition = response.headers()["Content-Disposition"]
|
||||||
CachedFile(
|
val mimeType = response.body()?.contentType()?.toString()
|
||||||
type = it.type,
|
val url = response.raw().request.url.toString()
|
||||||
status = it.status,
|
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||||
progress = it.progress,
|
return if (name.contains(".")) name else "$remoteId.epub"
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@
|
||||||
|
|
||||||
package org.dueattendant149.bookshelf.domain.repository
|
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
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
|
||||||
interface CacheRepository {
|
interface CacheRepository {
|
||||||
suspend fun cacheBook(book: Book): Result<Unit>
|
/**
|
||||||
suspend fun deleteCache(remoteId: String): Result<Unit>
|
* Ensures the ebook file for [book] is available locally.
|
||||||
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
|
*
|
||||||
|
* @return the local database id of the book (existing or newly inserted).
|
||||||
|
*/
|
||||||
|
suspend fun cacheBook(book: Book): Result<Int>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,20 +9,18 @@ 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.Job
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
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.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.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase
|
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -33,8 +31,7 @@ data class RemoteLibraryState(
|
||||||
val selectedLibrary: RemoteLibrary? = null,
|
val selectedLibrary: RemoteLibrary? = null,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
val cachingBookId: String? = null,
|
||||||
val offlineOnly: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -44,13 +41,13 @@ class RemoteLibraryModel
|
||||||
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
private val fetchLibrariesUseCase: FetchLibrariesUseCase,
|
||||||
private val fetchBooksUseCase: FetchBooksUseCase,
|
private val fetchBooksUseCase: FetchBooksUseCase,
|
||||||
private val cacheBookUseCase: CacheBookUseCase,
|
private val cacheBookUseCase: CacheBookUseCase,
|
||||||
private val deleteCacheUseCase: DeleteCacheUseCase,
|
|
||||||
private val getCacheStatusUseCase: GetCacheStatusUseCase,
|
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _state = MutableStateFlow(RemoteLibraryState())
|
private val _state = MutableStateFlow(RemoteLibraryState())
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
private var cacheStatusJob: Job? = null
|
|
||||||
|
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadLibraries()
|
loadLibraries()
|
||||||
|
|
@ -90,55 +87,25 @@ class RemoteLibraryModel
|
||||||
error = result.exceptionOrNull()?.message,
|
error = result.exceptionOrNull()?.message,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
observeCacheStatuses()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cacheBook(book: Book) {
|
fun openBook(book: Book) {
|
||||||
|
if (!book.hasEbook || book.remoteId.isBlank()) return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val result = cacheBookUseCase(book)
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
if (result.isFailure) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
_state.update {
|
cacheBookUseCase(book)
|
||||||
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observeCacheStatuses()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
|
|
||||||
fun deleteCache(book: Book) {
|
result
|
||||||
viewModelScope.launch {
|
.onSuccess { bookId ->
|
||||||
val result = deleteCacheUseCase(book.remoteId)
|
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
|
||||||
if (result.isFailure) {
|
}
|
||||||
_state.update {
|
.onFailure { error ->
|
||||||
it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed")
|
_state.update { it.copy(error = error.message ?: "Could not open book") }
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,77 +7,71 @@
|
||||||
package org.dueattendant149.bookshelf.ui.bookshelf
|
package org.dueattendant149.bookshelf.ui.bookshelf
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material.ExperimentalMaterialApi
|
import androidx.compose.material3.Button
|
||||||
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.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
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.dueattendant149.bookshelf.R
|
import org.dueattendant149.bookshelf.R
|
||||||
import org.dueattendant149.bookshelf.presentation.bookshelf.RemoteLibraryModel
|
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
|
@Composable
|
||||||
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
val state by model.state.collectAsStateWithLifecycle()
|
val state by model.state.collectAsStateWithLifecycle()
|
||||||
val refreshState = rememberPullRefreshState(
|
|
||||||
refreshing = state.isLoading,
|
|
||||||
onRefresh = model::loadLibraries
|
|
||||||
)
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(stringResource(R.string.bookshelf_screen)) }
|
title = { Text("Bookshelf") }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Box(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.pullRefresh(refreshState)
|
.padding(padding),
|
||||||
.padding(padding)
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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 {
|
item {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.bookshelf_libraries_header),
|
text = error,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
color = MaterialTheme.colorScheme.error,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items(state.libraries.size) { index ->
|
items(state.libraries.size) { index ->
|
||||||
val library = state.libraries[index]
|
val library = state.libraries[index]
|
||||||
|
|
@ -86,43 +80,15 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
onClick = { model.selectLibrary(library) },
|
onClick = { model.selectLibrary(library) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
Text(
|
||||||
text = library.name,
|
text = library.name,
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(
|
text = "${library.mediaType} • ${library.itemCount} items",
|
||||||
R.string.bookshelf_library_subtitle,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
library.mediaType,
|
modifier = Modifier.padding(horizontal = 16.dp).padding(bottom = 16.dp)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,64 +98,30 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
ListItem(
|
|
||||||
headlineContent = {
|
|
||||||
Text(
|
Text(
|
||||||
text = book.title,
|
text = book.title,
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
)
|
)
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
Text(
|
Text(
|
||||||
text = book.author.getAsString()
|
text = book.author.getAsString() ?: "",
|
||||||
?: stringResource(R.string.unknown_author)
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
}
|
if (book.hasEbook) {
|
||||||
)
|
Button(
|
||||||
}
|
onClick = { model.openBook(book) },
|
||||||
}
|
enabled = state.cachingBookId != book.remoteId,
|
||||||
|
|
||||||
if (state.isLoading && state.libraries.isEmpty() && state.books.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 32.dp),
|
.padding(horizontal = 16.dp)
|
||||||
contentAlignment = Alignment.Center
|
.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
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue