Fixed lagging in downloads screen, but screen position in the epub remains broken. Updated README.md.

This commit is contained in:
sNice/Python
2026-03-31 09:59:05 -05:00
parent 271651ff02
commit 667d4e1a7b
4 changed files with 64 additions and 28 deletions
+14 -6
View File
@@ -1,4 +1,4 @@
# AO3 Client # AO3 Client (v1.0)
A feature-rich Android client for Archive of Our Own (AO3), designed for a seamless browsing, downloading, and reading experience. A feature-rich Android client for Archive of Our Own (AO3), designed for a seamless browsing, downloading, and reading experience.
@@ -13,19 +13,23 @@ A feature-rich Android client for Archive of Our Own (AO3), designed for a seaml
- **Built-in EPUB Reader**: - **Built-in EPUB Reader**:
- Custom reader optimized for fanfiction. - Custom reader optimized for fanfiction.
- Dark mode by default for comfortable reading. - Dark mode by default for comfortable reading.
- Remembers your scroll position for every work. - **Persistent Progress**: Remembers your scroll position for every work using a deterministic layout-based restoration.
- **Offline Mode**: A dedicated toggle to browse your downloaded library without an internet connection. - **Offline Mode**: A dedicated toggle to browse your downloaded library without an internet connection.
- **Library Management**: - **Library Management**:
- Search through your downloaded works. - Search through your downloaded works.
- Sort by Name, Date, or File Size. - Sort by Name, Date, or File Size.
- Move, delete, and manually manage your files. - Move, delete, and manually manage your files with safety confirmations.
- **State Persistence**: The app remembers where you left off, including your last visited URL and the last book you were reading. - **State Persistence**: The app remembers where you left off, including your last visited URL and the last book you were reading, powered by **Jetpack DataStore**.
## Tech Stack ## Tech Stack & Architecture
This project follows **Clean Architecture** principles and modern Android development practices:
- **Language**: [Kotlin](https://kotlinlang.org/) - **Language**: [Kotlin](https://kotlinlang.org/)
- **UI Framework**: [Jetpack Compose](https://developer.android.com/jetpack/compose) - **UI Framework**: [Jetpack Compose](https://developer.android.com/jetpack/compose)
- **Navigation**: [Compose Navigation](https://developer.android.com/jetpack/compose/navigation) - **State Management**: [ViewModel](https://developer.android.com/topic/libraries/architecture/viewmodel) with [StateFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/)
- **Persistence**: [Jetpack DataStore](https://developer.android.com/topic/libraries/architecture/datastore) (Preferences)
- **Concurrency**: [Kotlin Coroutines](https://kotlinlang.org/docs/coroutines-overview.html) for non-blocking I/O
- **EPUB Engine**: [epublib](https://github.com/psiegman/epublib) - **EPUB Engine**: [epublib](https://github.com/psiegman/epublib)
- **Design System**: Material 3 - **Design System**: Material 3
@@ -56,6 +60,10 @@ A feature-rich Android client for Archive of Our Own (AO3), designed for a seaml
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request.
## Acknowledgements
This project was developed and refactored with the assistance of **AI technology**, which helped in modernizing the architecture, improving performance, and implementing robust state management.
## License ## License
This project is licensed under the MIT License - see the LICENSE file for details. This project is licensed under the MIT License - see the LICENSE file for details.
@@ -213,7 +213,10 @@ fun DownloadsScreen(
} }
} }
items(items) { item -> items(
items = items,
key = { it.absolutePath }
) { item ->
ListItem( ListItem(
headlineContent = { Text(item.name) }, headlineContent = { Text(item.name) },
supportingContent = { supportingContent = {
@@ -1,5 +1,6 @@
package net.pythonsden.ao3_.reader package net.pythonsden.ao3_.reader
import android.view.ViewTreeObserver
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -19,6 +20,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.Color import androidx.compose.ui.graphics.Color
@@ -74,12 +78,13 @@ fun ReaderScreen(
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} }
is ReaderUiState.Success -> { is ReaderUiState.Success -> {
var hasRestoredScroll by remember(file.absolutePath) { mutableStateOf(false) }
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { ctx -> factory = { ctx ->
WebView(ctx).apply { WebView(ctx).apply {
setBackgroundColor(android.graphics.Color.parseColor("#121212")) setBackgroundColor(android.graphics.Color.parseColor("#121212"))
// Security: Disable JavaScript for EPUB reading unless strictly needed
settings.javaScriptEnabled = false settings.javaScriptEnabled = false
settings.allowFileAccess = false settings.allowFileAccess = false
settings.allowContentAccess = false settings.allowContentAccess = false
@@ -87,18 +92,33 @@ fun ReaderScreen(
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url) super.onPageFinished(view, url)
if (state.initialScroll > 0) { if (!hasRestoredScroll && state.initialScroll > 0) {
// Restore scroll position
view?.scrollTo(0, state.initialScroll) view?.scrollTo(0, state.initialScroll)
// Multiple attempts to ensure it works after layout
view?.postDelayed({ view.scrollTo(0, state.initialScroll) }, 100)
view?.postDelayed({ view.scrollTo(0, state.initialScroll) }, 300)
} }
} }
} }
val layoutListener = object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
if (!hasRestoredScroll && state.initialScroll > 0) {
if (contentHeight > 0) {
scrollTo(0, state.initialScroll)
// If we've scrolled to or past the target, or reached bottom
if (scrollY >= state.initialScroll || scrollY > 0) {
hasRestoredScroll = true
}
}
} else if (state.initialScroll == 0) {
hasRestoredScroll = true
}
}
}
viewTreeObserver.addOnGlobalLayoutListener(layoutListener)
setOnScrollChangeListener { _, _, _, scrollY, _ -> setOnScrollChangeListener { _, _, _, scrollY, _ ->
viewModel.saveScrollPosition(file.absolutePath, scrollY) if (hasRestoredScroll) {
viewModel.saveScrollPosition(file.absolutePath, scrollY)
}
} }
} }
}, },
@@ -2,11 +2,13 @@ package net.pythonsden.ao3_.viewmodel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.pythonsden.ao3_.data.repository.EpubRepository import net.pythonsden.ao3_.data.repository.EpubRepository
import net.pythonsden.ao3_.data.repository.FileRepository import net.pythonsden.ao3_.data.repository.FileRepository
import java.io.File import java.io.File
@@ -46,23 +48,26 @@ class DownloadsViewModel(
} }
private suspend fun refreshFiles(dir: File, query: String, sort: SortOrder) { private suspend fun refreshFiles(dir: File, query: String, sort: SortOrder) {
val allItems = fileRepository.listFiles(dir) withContext(Dispatchers.Default) {
val filtered = if (query.isBlank()) { val allItems = fileRepository.listFiles(dir)
allItems val filtered = if (query.isBlank()) {
} else { allItems
allItems.filter { it.name.contains(query, ignoreCase = true) } } else {
} allItems.filter { it.name.contains(query, ignoreCase = true) }
}
_items.value = filtered.sortedWith { f1, f2 -> val sorted = filtered.sortedWith { f1, f2 ->
if (f1.isDirectory && !f2.isDirectory) -1 if (f1.isDirectory && !f2.isDirectory) -1
else if (!f1.isDirectory && f2.isDirectory) 1 else if (!f1.isDirectory && f2.isDirectory) 1
else { else {
when (sort) { when (sort) {
SortOrder.NAME -> f1.name.lowercase().compareTo(f2.name.lowercase()) SortOrder.NAME -> f1.name.lowercase().compareTo(f2.name.lowercase())
SortOrder.DATE -> f2.lastModified().compareTo(f1.lastModified()) SortOrder.DATE -> f2.lastModified().compareTo(f1.lastModified())
SortOrder.SIZE -> f2.length().compareTo(f1.length()) SortOrder.SIZE -> f2.length().compareTo(f1.length())
}
} }
} }
_items.value = sorted
} }
} }