Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
This commit is contained in:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -6,135 +6,106 @@ import android.net.Uri
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aryan.reader.R
|
||||
import com.aryan.reader.shared.opds.SharedOpdsController
|
||||
import com.aryan.reader.shared.opds.SharedOpdsDownloadNamer
|
||||
import com.aryan.reader.shared.opds.SharedOpdsSearch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.Response
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val repository = OpdsRepository(application)
|
||||
private val controller = SharedOpdsController(
|
||||
repository = repository,
|
||||
feedLoadErrorMessage = { error ->
|
||||
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
|
||||
},
|
||||
idFactory = { UUID.randomUUID().toString() }
|
||||
)
|
||||
|
||||
private val _uiState = MutableStateFlow(OpdsScreenState())
|
||||
private val _uiState = MutableStateFlow(controller.state)
|
||||
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
|
||||
|
||||
private val urlStack = mutableListOf<String>()
|
||||
|
||||
private val _downloadingEntries = MutableStateFlow<Set<String>>(emptySet())
|
||||
val downloadingEntries: StateFlow<Set<String>> = _downloadingEntries.asStateFlow()
|
||||
|
||||
private fun fetchUrl(url: String, isPagination: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) }
|
||||
|
||||
val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
|
||||
result.onSuccess { newFeed ->
|
||||
val template = newFeed.searchUrl ?: _uiState.value.searchUrlTemplate
|
||||
if (!isPagination) {
|
||||
if (urlStack.isEmpty() || urlStack.last() != url) {
|
||||
urlStack.add(url)
|
||||
}
|
||||
_uiState.update { it.copy(isLoading = false, currentFeed = newFeed, searchUrlTemplate = template) }
|
||||
} else {
|
||||
_uiState.update { state ->
|
||||
val currentEntries = state.currentFeed?.entries ?: emptyList()
|
||||
state.copy(
|
||||
isLoading = false,
|
||||
currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
|
||||
searchUrlTemplate = template
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = getApplication<Application>().getString(R.string.opds_error_load_feed, e.message.orEmpty())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadNextPage() {
|
||||
val nextUrl = _uiState.value.currentFeed?.nextUrl
|
||||
if (nextUrl != null && !_uiState.value.isLoading) {
|
||||
fetchUrl(nextUrl, isPagination = true)
|
||||
viewModelScope.launch {
|
||||
controller.loadNextPage(::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadState(val isDownloading: Boolean, val progress: Float? = null)
|
||||
|
||||
private val _downloadingState = MutableStateFlow<Map<String, DownloadState>>(emptyMap())
|
||||
val downloadingState: StateFlow<Map<String, DownloadState>> = _downloadingState.asStateFlow()
|
||||
|
||||
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
|
||||
val downloadUrl = acquisition.url
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_downloadingState.update { it + (entry.id to DownloadState(true, 0f)) }
|
||||
viewModelScope.launch {
|
||||
updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
|
||||
try {
|
||||
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
|
||||
val request = Request.Builder().url(downloadUrl).build()
|
||||
val tempFile = withContext(Dispatchers.IO) {
|
||||
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
|
||||
val request = Request.Builder().url(downloadUrl).build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw OpdsDownloadFailedException(
|
||||
context.getString(R.string.opds_error_download_failed, response.message)
|
||||
)
|
||||
}
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body
|
||||
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||
val contentLength = body.contentLength()
|
||||
val body = response.body
|
||||
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
|
||||
val contentLength = body.contentLength()
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
|
||||
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||
|
||||
val ext = resolveOpdsDownloadExtension(acquisition, response)
|
||||
body.byteStream().use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var totalRead = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50)
|
||||
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
|
||||
while (true) {
|
||||
val bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
|
||||
val input = body.byteStream()
|
||||
val output = tempFile.outputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var bytesRead: Int
|
||||
var totalRead = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
input.use { inp ->
|
||||
output.use { out ->
|
||||
while (inp.read(buffer).also { bytesRead = it } != -1) {
|
||||
out.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
// Throttle UI updates to 4-5 fps
|
||||
if (now - lastProgressUpdate > 200) {
|
||||
val progress = totalRead.toFloat() / contentLength.toFloat()
|
||||
_downloadingState.update { it + (entry.id to DownloadState(true, progress)) }
|
||||
lastProgressUpdate = now
|
||||
if (contentLength > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressUpdate > 200) {
|
||||
val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
|
||||
withContext(Dispatchers.Main) {
|
||||
updateDownloadState(
|
||||
entry.id,
|
||||
OpdsDownloadState(isDownloading = true, progress = progress)
|
||||
)
|
||||
}
|
||||
lastProgressUpdate = now
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
onDownloaded(Uri.fromFile(tempFile))
|
||||
}
|
||||
} else {
|
||||
Timber.e("Download failed: ${response.code}")
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_failed, response.message)) }
|
||||
}
|
||||
|
||||
onDownloaded(Uri.fromFile(tempFile))
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Download error")
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.opds_error_download_error, e.message.orEmpty())) }
|
||||
val message = if (e is OpdsDownloadFailedException) {
|
||||
e.message.orEmpty()
|
||||
} else {
|
||||
context.getString(R.string.opds_error_download_error, e.message.orEmpty())
|
||||
}
|
||||
emitState(controller.setErrorMessage(message))
|
||||
} finally {
|
||||
_downloadingState.update { it - entry.id }
|
||||
updateDownloadState(entry.id, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -147,69 +118,55 @@ class OpdsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
)
|
||||
}
|
||||
|
||||
init {
|
||||
loadCatalogs()
|
||||
}
|
||||
|
||||
private fun loadCatalogs() {
|
||||
_uiState.update { it.copy(catalogs = repository.getCatalogs()) }
|
||||
}
|
||||
|
||||
fun addCatalog(title: String, url: String, username: String?, password: String?) {
|
||||
repository.addCatalog(title, url, username, password)
|
||||
loadCatalogs()
|
||||
emitState(controller.addCatalog(title, url, username, password))
|
||||
}
|
||||
|
||||
fun removeCatalog(id: String) {
|
||||
repository.removeCatalog(id)
|
||||
loadCatalogs()
|
||||
emitState(controller.removeCatalog(id))
|
||||
}
|
||||
|
||||
fun openCatalog(catalog: OpdsCatalog) {
|
||||
urlStack.clear()
|
||||
_uiState.update { it.copy(searchUrlTemplate = null, currentCatalog = catalog) }
|
||||
fetchUrl(catalog.url)
|
||||
}
|
||||
|
||||
fun openFeedUrl(url: String) {
|
||||
fetchUrl(url)
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean {
|
||||
if (urlStack.size > 1) {
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
val previousUrl = urlStack.last()
|
||||
urlStack.removeAt(urlStack.lastIndex)
|
||||
fetchUrl(previousUrl)
|
||||
return true
|
||||
} else {
|
||||
urlStack.clear()
|
||||
_uiState.update { it.copy(isViewingCatalog = false, currentFeed = null, searchUrlTemplate = null, currentCatalog = null) }
|
||||
return false
|
||||
viewModelScope.launch {
|
||||
controller.openCatalog(catalog, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun openFeedUrl(url: String) {
|
||||
viewModelScope.launch {
|
||||
controller.openFeedUrl(url, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateBack(): Boolean {
|
||||
val returnsToPreviousFeed = controller.hasFeedHistory()
|
||||
viewModelScope.launch {
|
||||
controller.navigateBack(::emitState)
|
||||
}
|
||||
return returnsToPreviousFeed
|
||||
}
|
||||
|
||||
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
|
||||
repository.updateCatalog(id, title, url, username, password)
|
||||
loadCatalogs()
|
||||
emitState(controller.updateCatalog(id, title, url, username, password))
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
val searchLink = _uiState.value.searchUrlTemplate ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
|
||||
val finalUrl = SharedOpdsSearch.buildSearchUrl(searchLink, query) { openSearchUrl ->
|
||||
val catalog = _uiState.value.currentCatalog
|
||||
repository.getSearchTemplate(openSearchUrl, catalog?.username, catalog?.password)
|
||||
}
|
||||
|
||||
openFeedUrl(finalUrl)
|
||||
controller.search(query, ::emitState)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
emitState(controller.clearError())
|
||||
}
|
||||
|
||||
private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
|
||||
emitState(controller.updateDownloadState(entryId, downloadState))
|
||||
}
|
||||
|
||||
private fun emitState(state: OpdsScreenState) {
|
||||
_uiState.value = state
|
||||
}
|
||||
|
||||
private class OpdsDownloadFailedException(message: String) : Exception(message)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue