diff --git a/app/src/main/java/io/aatricks/novelscraper/EasyReaderApplication.kt b/app/src/main/java/io/aatricks/novelscraper/EasyReaderApplication.kt index 09e1b39..c8d50f2 100644 --- a/app/src/main/java/io/aatricks/novelscraper/EasyReaderApplication.kt +++ b/app/src/main/java/io/aatricks/novelscraper/EasyReaderApplication.kt @@ -20,21 +20,24 @@ class EasyReaderApplication : Application(), SingletonImageLoader.Factory { override fun newImageLoader(context: PlatformContext): ImageLoader { return ImageLoader.Builder(context) - .memoryCache { - MemoryCache.Builder() - .maxSizePercent(context, 0.25) - .build() - } - .diskCache { - DiskCache.Builder() - .directory(context.cacheDir.resolve("image_cache").absolutePath.toPath()) - .maxSizeBytes(1024 * 1024 * 512) // 512MB - .build() - } - .components { - add(OkHttpNetworkFetcherFactory(okHttpClient)) - } + .memoryCache { buildMemoryCache(context) } + .diskCache { buildDiskCache(context) } + .components { add(OkHttpNetworkFetcherFactory(okHttpClient)) } .crossfade(false) .build() } + + private fun buildMemoryCache(context: PlatformContext): MemoryCache { + return MemoryCache.Builder() + .maxSizePercent(context, 0.25) + .build() + } + + private fun buildDiskCache(context: PlatformContext): DiskCache { + val directory = context.cacheDir.resolve("image_cache").absolutePath.toPath() + return DiskCache.Builder() + .directory(directory) + .maxSizeBytes(512 * 1024 * 1024) + .build() + } } \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/novelscraper/MainActivity.kt b/app/src/main/java/io/aatricks/novelscraper/MainActivity.kt index 2d79ee4..2dcaaee 100644 --- a/app/src/main/java/io/aatricks/novelscraper/MainActivity.kt +++ b/app/src/main/java/io/aatricks/novelscraper/MainActivity.kt @@ -110,17 +110,17 @@ class MainActivity : ComponentActivity() { handleIntent(intent) } - private fun checkForLibraryUpdates() { + private fun checkForLibraryUpdates(): Unit { val prefs = io.aatricks.novelscraper.data.local.PreferencesManager(applicationContext) lifecycleScope.launch { - try { + runCatching { libraryRepository.refreshLibraryUpdates(exploreRepository) prefs.lastUpdateCheckTime = System.currentTimeMillis() - } catch (_: Exception) {} + } } } - private fun handleIntent(intent: Intent?) { + private fun handleIntent(intent: Intent?): Unit { intent ?: return when (intent.action) { Intent.ACTION_VIEW -> { @@ -143,80 +143,82 @@ class MainActivity : ComponentActivity() { } } - private fun handleWebUrl(url: String) { + private fun handleWebUrl(url: String): Unit { val title = io.aatricks.novelscraper.util.TextUtils.extractTitleFromUrl(url) libraryViewModel.addItem(title = title, url = url, contentType = ContentType.WEB) readerViewModel.loadContent(url) } - private fun handleFilePicked(uri: Uri) { + private fun handleFilePicked(uri: Uri): Unit { if (uri.scheme == "content") { - try { + runCatching { contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) {} + } } - val fileName = FileUtils.getFileName(this, uri) ?: "Unknown" val fileType = FileUtils.detectFileType(this, uri) - val contentType = when (fileType) { - FileUtils.FileType.PDF -> ContentType.PDF - FileUtils.FileType.HTML -> ContentType.HTML - FileUtils.FileType.EPUB -> ContentType.EPUB - else -> { - Toast.makeText(this, "Unsupported file type", Toast.LENGTH_SHORT).show() - return - } + val contentType = mapFileTypeToContentType(fileType) ?: run { + Toast.makeText(this, "Unsupported file type", Toast.LENGTH_SHORT).show() + return } + val fileName = FileUtils.getFileName(this, uri) ?: "Unknown" val title = fileName.substringBeforeLast('.') libraryViewModel.addItem(title = title, url = uri.toString(), contentType = contentType) if (contentType == ContentType.EPUB) { - lifecycleScope.launch { - try { - val epubBook = contentRepository.getEpubBook(uri.toString()) - val firstHref = epubBook?.toc?.firstOrNull()?.href - if (firstHref != null) { - readerViewModel.loadEpubChapter(uri.toString(), firstHref, null) - } else { - readerViewModel.loadContent(uri.toString()) - } - } catch (e: Exception) { - readerViewModel.loadContent(uri.toString()) - } - } + loadEpubChapter(uri) } else { readerViewModel.loadContent(uri.toString()) } } - private fun checkPermissionsAndOpenFilePicker() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED) { - openFilePicker() - } else { - storagePermissionLauncher.launch(Manifest.permission.READ_MEDIA_IMAGES) + private fun mapFileTypeToContentType(fileType: FileUtils.FileType): ContentType? = when (fileType) { + FileUtils.FileType.PDF -> ContentType.PDF + FileUtils.FileType.HTML -> ContentType.HTML + FileUtils.FileType.EPUB -> ContentType.EPUB + else -> null + } + + private fun loadEpubChapter(uri: Uri): Unit { + lifecycleScope.launch { + runCatching { + val epubBook = contentRepository.getEpubBook(uri.toString()) + val firstHref = epubBook?.toc?.firstOrNull()?.href + if (firstHref != null) { + readerViewModel.loadEpubChapter(uri.toString(), firstHref, null) + } else { + readerViewModel.loadContent(uri.toString()) + } + }.onFailure { + readerViewModel.loadContent(uri.toString()) } - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - openFilePicker() + } + } + + private fun checkPermissionsAndOpenFilePicker(): Unit { + val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Manifest.permission.READ_MEDIA_IMAGES } else { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) { - openFilePicker() - } else { - storagePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) - } + Manifest.permission.READ_EXTERNAL_STORAGE + } + + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> openFilePicker() + ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -> openFilePicker() + else -> storagePermissionLauncher.launch(permission) } } - private fun openFilePicker() { + private fun openFilePicker(): Unit { val mimeTypes = arrayOf("text/html", "application/xhtml+xml", "application/pdf", "application/epub+zip") filePickerLauncher.launch(mimeTypes) } - override fun onPause() { + override fun onPause(): Unit { super.onPause() - try { + runCatching { readerViewModel.updateReadingProgress(readerViewModel.uiState.value.scrollProgress) - } catch (_: Exception) {} + } } } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/BaseRepository.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/BaseRepository.kt index 58eb576..586631e 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/BaseRepository.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/BaseRepository.kt @@ -14,11 +14,8 @@ abstract class BaseRepository(protected val tag: String) { fallback: T? = null, block: suspend () -> T ): T? = withContext(Dispatchers.IO) { - try { - block() - } catch (e: Exception) { - Log.e(tag, errorMessage, e) - fallback - } + kotlin.runCatching { block() } + .onFailure { e -> Log.e(tag, errorMessage, e) } + .getOrDefault(fallback) } } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt index c618bf9..3563554 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/ContentRepository.kt @@ -62,258 +62,392 @@ class ContentRepository @Inject constructor( } suspend fun loadContent(url: String): ContentResult = withContext(Dispatchers.IO) { - try { - if (url.startsWith("http://") || url.startsWith("https://")) return@withContext loadWebContent(url) - - if (url.startsWith("content://") || url.startsWith("file://") || url.contains("/storage/")) { - try { - val uri = Uri.parse(url) - val mime = context.contentResolver.getType(uri) - if (mime != null) { - return@withContext when { - mime.contains("pdf") -> loadPdfContent(url) - mime.contains("epub") || mime.contains("application/epub+zip") -> loadEpubContent(url) - mime.contains("html") || mime.contains("text") -> loadHtmlFile(url) - else -> ContentResult.Error("Unsupported MIME type: $mime") - } - } - } catch (_: Exception) {} - } - + runCatching { when { - url.endsWith(".pdf", ignoreCase = true) -> loadPdfContent(url) - url.endsWith(".epub", ignoreCase = true) -> loadEpubContent(url) - url.endsWith(".html", ignoreCase = true) || url.endsWith(".htm", ignoreCase = true) -> loadHtmlFile(url) + url.startsWith("http://") || url.startsWith("https://") -> loadWebContent(url) + isLocalFile(url) -> handleLocalFile(url) + url.lowercase().endsWith(".pdf") -> loadPdfContent(url) + url.lowercase().endsWith(".epub") -> loadEpubContent(url) + url.lowercase().run { endsWith(".html") || endsWith(".htm") } -> loadHtmlFile(url) else -> ContentResult.Error("Unsupported file type") } - } catch (e: Exception) { - ContentResult.Error("Failed to load content: ${e.message}", e) + }.getOrElse { e -> + ContentResult.Error("Failed to load content: ${e.message}", e as? Exception) + } + } + + private fun isLocalFile(url: String): Boolean = + url.startsWith("content://") || url.startsWith("file://") || url.contains("/storage/") + + private suspend fun handleLocalFile(url: String): ContentResult { + val uri = Uri.parse(url) + val mime = context.contentResolver.getType(uri) ?: return loadFileByExtension(url) + + return when { + mime.contains("pdf", ignoreCase = true) -> loadPdfContent(url) + mime.contains("epub", ignoreCase = true) || mime.contains("application/epub+zip", ignoreCase = true) -> loadEpubContent(url) + mime.contains("html", ignoreCase = true) || mime.contains("text", ignoreCase = true) -> loadHtmlFile(url) + else -> ContentResult.Error("Unsupported MIME type: $mime") + } + } + + private suspend fun loadFileByExtension(url: String): ContentResult = + when { + url.endsWith(".pdf", ignoreCase = true) -> loadPdfContent(url) + url.endsWith(".epub", ignoreCase = true) -> loadEpubContent(url) + url.endsWith(".html", ignoreCase = true) || url.endsWith(".htm", ignoreCase = true) -> loadHtmlFile(url) + else -> ContentResult.Error("Unsupported local file type") } + + private fun getReferer(url: String): String = try { + val uri = java.net.URI(url) + "${uri.scheme}://${uri.host}/" + } catch (e: Exception) { + url } private suspend fun loadWebContent(url: String): ContentResult = withContext(Dispatchers.IO) { - try { - val cachedFile = getCachedFile(url) - val document = if (cachedFile.exists()) Jsoup.parse(cachedFile, "UTF-8", url) - else { - val html = downloadHtml(url) - cachedFile.writeText(html) - Jsoup.parse(html, url) - } + val cachedFile = getCachedFile(url) + val document = if (cachedFile.exists()) { + Jsoup.parse(cachedFile, "UTF-8", url) + } else { + val html = downloadHtml(url) + cachedFile.writeText(html) + Jsoup.parse(html, url) + } + + val elements = htmlParser.parse(document, url) + val finalElements = if (elements.any { it is ContentElement.Image }) { + processImages(elements.filterIsInstance(), url) + } else { + elements + } - val title = document.title().takeIf { it.isNotBlank() } - val elements = htmlParser.parse(document, url) - val finalElements = if (elements.any { it is ContentElement.Image }) processImages(elements.filterIsInstance(), url) else elements + backgroundCacheImages(finalElements, url) + + ContentResult.Success( + elements = finalElements, + title = document.title().takeIf { it.isNotBlank() }, + url = url + ) + } - repositoryScope.launch { - finalElements.forEach { element -> - when (element) { - is ContentElement.Image -> launch { downloadAndCacheImage(element.url, url) } - is ContentElement.ImageGroup -> element.images.forEach { img -> launch { downloadAndCacheImage(img.url, url) } } - else -> {} + private fun backgroundCacheImages(elements: List, pageUrl: String): Unit { + repositoryScope.launch { + elements.forEach { element -> + when (element) { + is ContentElement.Image -> launch { downloadAndCacheImage(element.url, pageUrl) } + is ContentElement.ImageGroup -> element.images.forEach { img -> + launch { downloadAndCacheImage(img.url, pageUrl) } } + else -> {} } } - ContentResult.Success(finalElements, title, url) - } catch (e: Exception) { - ContentResult.Error("Failed to load web content: ${e.message}", e) } } suspend fun downloadAndCacheImage(imageUrl: String, pageUrl: String): File? = withContext(Dispatchers.IO) { - try { - if (!imageUrl.startsWith("http")) return@withContext null + if (!imageUrl.startsWith("http")) return@withContext null + + runCatching { val cachedFile = getCachedMediaFile(imageUrl) - if (cachedFile.exists()) return@withContext cachedFile + if (cachedFile.exists()) return@runCatching cachedFile - val uri = try { java.net.URI(pageUrl) } catch (e: Exception) { null } - val referer = if (uri != null) "${uri.scheme}://${uri.host}/" else pageUrl - val request = Request.Builder().url(imageUrl).addHeader("User-Agent", "Mozilla/5.0").addHeader("Referer", referer).build() + val request = Request.Builder() + .url(imageUrl) + .addHeader("User-Agent", "Mozilla/5.0") + .addHeader("Referer", getReferer(pageUrl)) + .build() - okHttpClient.newCall(request).execute().use { - if (it.isSuccessful) { - val body = it.body ?: return@withContext null + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@runCatching null + response.body?.let { body -> cachedFile.writeBytes(body.bytes()) - return@withContext cachedFile + cachedFile } } - null - } catch (e: Exception) { null } + }.getOrNull() } fun getCachedMediaFile(url: String): File = File(mediaCacheDir, url.hashCode().toString()) private fun downloadHtml(url: String): String { - val uri = try { java.net.URI(url) } catch (e: Exception) { null } - val referer = if (uri != null) "${uri.scheme}://${uri.host}/" else url - val request = Request.Builder().url(url).addHeader("User-Agent", "Mozilla/5.0").addHeader("Referer", referer).build() - okHttpClient.newCall(request).execute().use { - if (!it.isSuccessful) throw Exception("HTTP ${it.code}") - return it.body?.string() ?: throw Exception("Empty body") + val request = Request.Builder() + .url(url) + .addHeader("User-Agent", "Mozilla/5.0") + .addHeader("Referer", getReferer(url)) + .build() + + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw Exception("HTTP ${response.code}") + return response.body?.string() ?: throw Exception("Empty body") } } private suspend fun loadHtmlFile(filePath: String): ContentResult = withContext(Dispatchers.IO) { - try { + runCatching { val document = if (filePath.startsWith("content://") || filePath.startsWith("file://")) { val uri = Uri.parse(filePath) - context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { Jsoup.parse(it.readText(), uri.toString()) } - ?: return@withContext ContentResult.Error("Unable to read $filePath") + context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { + Jsoup.parse(it.readText(), uri.toString()) + } ?: throw Exception("Unable to read $filePath") } else { val file = File(filePath) - if (!file.exists()) return@withContext ContentResult.Error("File not found") + if (!file.exists()) throw Exception("File not found") Jsoup.parse(file, "UTF-8") } - val title = document.title().takeIf { it.isNotBlank() } - val elements = htmlParser.parse(document, filePath) - ContentResult.Success(elements, title, filePath) - } catch (e: Exception) { ContentResult.Error("Failed to load HTML: ${e.message}") } + + ContentResult.Success( + elements = htmlParser.parse(document, filePath), + title = document.title().takeIf { it.isNotBlank() }, + url = filePath + ) + }.getOrElse { e -> + ContentResult.Error("Failed to load HTML: ${e.message}") + } } - private fun getCachedFile(url: String): File = File(cacheDir, url.hashCode().toString() + ".html") + private fun getCachedFile(url: String): File = File(cacheDir, "${url.hashCode()}.html") fun isCached(url: String): Boolean = getCachedFile(url).exists() suspend fun fetchTitle(url: String): String? = withContext(Dispatchers.IO) { - try { - if (url.endsWith(".epub", ignoreCase = true) || url.contains("epub")) return@withContext getEpubBook(url)?.metadata?.title - if (url.endsWith(".pdf", ignoreCase = true) || url.contains("pdf")) { - return@withContext if (url.startsWith("content://")) Uri.parse(url).lastPathSegment?.substringBeforeLast(".") ?: "PDF" - else File(url).nameWithoutExtension - } - if (!url.startsWith("http")) return@withContext null - val cached = getCachedFile(url) - val doc = if (cached.exists()) Jsoup.parse(cached, "UTF-8", url) - else { - val html = downloadHtml(url) - cached.writeText(html) - Jsoup.parse(html, url) + runCatching { + when { + url.endsWith(".epub", ignoreCase = true) || url.contains("epub") -> + getEpubBook(url)?.metadata?.title + + url.endsWith(".pdf", ignoreCase = true) || url.contains("pdf") -> { + if (url.startsWith("content://")) { + Uri.parse(url).lastPathSegment?.substringBeforeLast(".") ?: "PDF" + } else { + File(url).nameWithoutExtension + } + } + + url.startsWith("http") -> { + val cached = getCachedFile(url) + val doc = if (cached.exists()) { + Jsoup.parse(cached, "UTF-8", url) + } else { + val html = downloadHtml(url) + cached.writeText(html) + Jsoup.parse(html, url) + } + doc.title().takeIf { it.isNotBlank() } + } + else -> null } - doc.title().takeIf { it.isNotBlank() } - } catch (e: Exception) { null } + }.getOrNull() } suspend fun prefetch(url: String): Boolean = withContext(Dispatchers.IO) { - try { - if (url.startsWith("http")) { - val html = downloadHtml(url) - getCachedFile(url).writeText(html) - val doc = Jsoup.parse(html, url) - htmlParser.parse(doc, url).filterIsInstance().forEach { - repositoryScope.launch { try { downloadAndCacheImage(it.url, url) } catch (_: Exception) {} } + runCatching { + when { + url.startsWith("http") -> { + val html = downloadHtml(url) + getCachedFile(url).writeText(html) + val doc = Jsoup.parse(html, url) + + htmlParser.parse(doc, url) + .filterIsInstance() + .forEach { img -> + repositoryScope.launch { + runCatching { downloadAndCacheImage(img.url, url) } + } + } + true + } + + url.endsWith(".epub", ignoreCase = true) || url.contains("epub") -> + prefetchEpub(url) + + url.startsWith("content://") || url.startsWith("file://") -> { + context.contentResolver.openInputStream(Uri.parse(url))?.close() + true } - true - } else if (url.endsWith(".epub", ignoreCase = true) || url.contains("epub")) prefetchEpub(url) - else { - if (url.startsWith("content://") || url.startsWith("file://")) { - try { Uri.parse(url).let { context.contentResolver.openInputStream(it)?.close() }; true } catch (e: Exception) { false } - } else File(url).exists() + + else -> File(url).exists() } - } catch (e: Exception) { false } + }.getOrDefault(false) } private suspend fun processImages(images: List, url: String): List { val imagesWithDims = withContext(Dispatchers.IO) { - images.map { img -> async { DIMENSION_SEMAPHORE.withPermit { val dims = fetchImageDimensions(img.url, url); if (dims != null) img.copy(width = dims.first, height = dims.second) else img } } }.awaitAll() + images.map { img -> + async { + DIMENSION_SEMAPHORE.withPermit { + fetchImageDimensions(img.url, url)?.let { (w, h) -> + img.copy(width = w, height = h) + } ?: img + } + } + }.awaitAll() } - val processed = mutableListOf() - var i = 0 - while (i < imagesWithDims.size) { - val current = imagesWithDims[i] - if (processed.isNotEmpty()) { - val last = processed.last() - if (last is ContentElement.Image && last.width > 0 && current.width > 0 && last.width == current.width) { - val lastRatio = last.height.toFloat() / last.width - val currentRatio = current.height.toFloat() / current.width - if ((currentRatio < 0.8f || lastRatio < 0.8f) && lastRatio + currentRatio < 2.1f) { - processed.removeAt(processed.size - 1); processed.add(ContentElement.ImageGroup(listOf(last, current))); i++; continue - } - } else if (last is ContentElement.ImageGroup) { - val lastInGroup = last.images.last() - if (lastInGroup.width > 0 && current.width > 0 && lastInGroup.width == current.width) { - val groupRatio = last.images.sumOf { it.height }.toFloat() / lastInGroup.width - val currentRatio = current.height.toFloat() / current.width - if (currentRatio < 0.8f && groupRatio + currentRatio < 2.1f) { - processed.removeAt(processed.size - 1); processed.add(ContentElement.ImageGroup(last.images + current)); i++; continue - } - } + + return groupSimilarImages(imagesWithDims) + } + + private fun groupSimilarImages(images: List): List { + if (images.isEmpty()) return emptyList() + val processed = mutableListOf(images[0]) + + for (i in 1 until images.size) { + val current = images[i] + val last = processed.last() + + when { + shouldGroupWithLastImage(last, current) -> { + processed[processed.size - 1] = ContentElement.ImageGroup(listOf(last as ContentElement.Image, current)) + } + shouldGroupWithLastGroup(last, current) -> { + val group = last as ContentElement.ImageGroup + processed[processed.size - 1] = ContentElement.ImageGroup(group.images + current) } + else -> processed.add(current) } - processed.add(current); i++ } return processed } + private fun shouldGroupWithLastImage(last: ContentElement, current: ContentElement.Image): Boolean { + if (last !is ContentElement.Image || last.width <= 0 || current.width <= 0 || last.width != current.width) { + return false + } + val lastRatio = last.height.toFloat() / last.width + val currentRatio = current.height.toFloat() / current.width + return (currentRatio < 0.8f || lastRatio < 0.8f) && lastRatio + currentRatio < 2.1f + } + + private fun shouldGroupWithLastGroup(last: ContentElement, current: ContentElement.Image): Boolean { + if (last !is ContentElement.ImageGroup) return false + val lastInGroup = last.images.last() + if (lastInGroup.width <= 0 || current.width <= 0 || lastInGroup.width != current.width) { + return false + } + val groupRatio = last.images.sumOf { it.height }.toFloat() / lastInGroup.width + val currentRatio = current.height.toFloat() / current.width + return currentRatio < 0.8f && groupRatio + currentRatio < 2.1f + } + private suspend fun fetchImageDimensions(imageUrl: String, pageUrl: String): Pair? = withContext(Dispatchers.IO) { - try { - if (!imageUrl.startsWith("http")) return@withContext null + if (!imageUrl.startsWith("http")) return@withContext null + + runCatching { val cached = getCachedMediaFile(imageUrl) if (cached.exists()) { val opt = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(cached.absolutePath, opt) - if (opt.outWidth > 0) return@withContext Pair(opt.outWidth, opt.outHeight) + if (opt.outWidth > 0) return@runCatching Pair(opt.outWidth, opt.outHeight) } - val uri = try { java.net.URI(pageUrl) } catch (e: Exception) { null } - val referer = if (uri != null) "${uri.scheme}://${uri.host}/" else pageUrl - val req = Request.Builder().url(imageUrl).addHeader("User-Agent", "Mozilla/5.0").addHeader("Referer", referer).addHeader("Range", "bytes=0-16383").build() - okHttpClient.newCall(req).execute().use { - if (req.url.toString() == imageUrl && it.isSuccessful) { + + val req = Request.Builder() + .url(imageUrl) + .addHeader("User-Agent", "Mozilla/5.0") + .addHeader("Referer", getReferer(pageUrl)) + .addHeader("Range", "bytes=0-16383") + .build() + + okHttpClient.newCall(req).execute().use { response -> + if (response.isSuccessful) { val opt = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeStream(it.body?.byteStream(), null, opt) - if (opt.outWidth > 0) return@withContext Pair(opt.outWidth, opt.outHeight) - } + BitmapFactory.decodeStream(response.body?.byteStream(), null, opt) + if (opt.outWidth > 0) Pair(opt.outWidth, opt.outHeight) else null + } else null } - null - } catch (e: Exception) { null } + }.getOrNull() } private suspend fun loadPdfContent(filePath: String): ContentResult = withContext(Dispatchers.IO) { - try { + runCatching { val paragraphs = mutableListOf() val pdfDoc = if (filePath.startsWith("content://")) { val uri = Uri.parse(filePath) - context.contentResolver.openInputStream(uri)?.use { PdfDocument(PdfReader(it)) } ?: return@withContext ContentResult.Error("PDF not found") + context.contentResolver.openInputStream(uri)?.use { + PdfDocument(PdfReader(it)) + } ?: throw Exception("PDF not found") } else { val file = File(filePath) - if (!file.exists()) return@withContext ContentResult.Error("PDF not found") + if (!file.exists()) throw Exception("PDF not found") PdfDocument(PdfReader(file)) } - try { - for (i in 1..pdfDoc.numberOfPages) { - PdfTextExtractor.getTextFromPage(pdfDoc.getPage(i)).lines() + + pdfDoc.use { doc -> + for (i in 1..doc.numberOfPages) { + PdfTextExtractor.getTextFromPage(doc.getPage(i)).lines() .filterNot { it.trim().matches(Regex("^\\d+$")) } - .joinToString("\n").split(Regex("\n\\s*\\n")) - .map { it.trim() }.filter { it.length > 20 }.forEach { paragraphs.add(it) } + .joinToString("\n") + .split(Regex("\n\\s*\\n")) + .map { it.trim() } + .filter { it.length > 20 } + .forEach { paragraphs.add(it) } } - } finally { pdfDoc.close() } - if (paragraphs.isEmpty()) return@withContext ContentResult.Error("No text in PDF") - val title = if (filePath.startsWith("content://")) Uri.parse(filePath).lastPathSegment ?: "PDF" else File(filePath).nameWithoutExtension + } + + if (paragraphs.isEmpty()) throw Exception("No text in PDF") + + val title = if (filePath.startsWith("content://")) { + Uri.parse(filePath).lastPathSegment ?: "PDF" + } else { + File(filePath).nameWithoutExtension + } + ContentResult.Success(paragraphs.map { ContentElement.Text(it) }, title, filePath) - } catch (e: Exception) { ContentResult.Error("PDF Error: ${e.message}") } + }.getOrElse { e -> + ContentResult.Error("PDF Error: ${e.message}") + } } private suspend fun loadEpubContent(filePath: String, chapterHref: String? = null): ContentResult = withContext(Dispatchers.IO) { - try { - val book = epubBookCache[filePath] ?: parseEpubFile(filePath).also { epubBookCache[filePath] = it } - val href = chapterHref ?: book.spine.firstOrNull() ?: return@withContext ContentResult.Error("No chapters") + runCatching { + val book = getEpubBook(filePath) ?: throw Exception("Failed to load EPUB") + val href = chapterHref ?: book.spine.firstOrNull() ?: throw Exception("No chapters") val chapter = loadEpubChapter(filePath, book, href) ContentResult.Success(chapter.content, chapter.title ?: book.metadata.title, "$filePath#$href") - } catch (e: Exception) { ContentResult.Error("EPUB Error: ${e.message}") } + }.getOrElse { e -> + ContentResult.Error("EPUB Error: ${e.message}") + } } private fun parseEpubFile(filePath: String): EpubBook { - val stream = if (filePath.startsWith("content://")) context.contentResolver.openInputStream(Uri.parse(filePath)) ?: throw Exception("EPUB stream error") else File(filePath).inputStream() + val stream = if (filePath.startsWith("content://")) { + context.contentResolver.openInputStream(Uri.parse(filePath)) ?: throw Exception("EPUB stream error") + } else { + File(filePath).inputStream() + } + val entries = mutableMapOf() - ZipInputStream(stream).use { zip -> var e = zip.nextEntry; while (e != null) { if (!e.isDirectory) entries[e.name] = zip.readBytes(); zip.closeEntry(); e = zip.nextEntry } } + ZipInputStream(stream).use { zip -> + var e = zip.nextEntry + while (e != null) { + if (!e.isDirectory) entries[e.name] = zip.readBytes() + zip.closeEntry() + e = zip.nextEntry + } + } + val cont = entries["META-INF/container.xml"] ?: throw Exception("No container.xml") val opfPath = Jsoup.parse(String(cont), "", org.jsoup.parser.Parser.xmlParser()).select("rootfile").attr("full-path") val opfDoc = Jsoup.parse(String(entries[opfPath] ?: throw Exception("No OPF")), "", org.jsoup.parser.Parser.xmlParser()) - val meta = EpubMetadata(opfDoc.select("metadata dc|title, title").first()?.text() ?: "Unknown", opfDoc.select("dc|creator").first()?.text()) + + val meta = EpubMetadata( + title = opfDoc.select("metadata dc|title, title").first()?.text() ?: "Unknown", + author = opfDoc.select("dc|creator").first()?.text() + ) + val base = opfPath.substringBeforeLast("/", "") val manifest = mutableMapOf() - opfDoc.select("manifest item").forEach { it.attr("id").let { id -> if (id.isNotBlank()) manifest[id] = if (base.isNotBlank()) "$base/${it.attr("href")}" else it.attr("href") } } + opfDoc.select("manifest item").forEach { + val id = it.attr("id") + if (id.isNotBlank()) { + val href = it.attr("href") + manifest[id] = if (base.isNotBlank()) "$base/$href" else href + } + } + val spine = mutableListOf() opfDoc.select("spine itemref").forEach { manifest[it.attr("idref")]?.let { h -> spine.add(h) } } + val toc = parseTocNcx(entries, manifest, base) ?: emptyList() return EpubBook(meta, toc, spine, manifest) } @@ -321,32 +455,73 @@ class ContentRepository @Inject constructor( private fun parseTocNcx(entries: Map, manifest: Map, base: String): List? { val ncx = manifest.values.firstOrNull { it.endsWith("toc.ncx") } ?: return null val doc = Jsoup.parse(String(entries[ncx] ?: return null), "", org.jsoup.parser.Parser.xmlParser()) - fun p(e: org.jsoup.nodes.Element): EpubTocItem { + + fun parsePoint(e: org.jsoup.nodes.Element): EpubTocItem { val src = e.select("content").attr("src").let { if (it.startsWith("/")) it.drop(1) else it } - return EpubTocItem(e.attr("id"), e.select("navLabel text").first()?.text() ?: "Chapter", (if (base.isNotBlank() && !src.contains("/")) "$base/$src" else src).substringBefore("#"), children = e.select("> navPoint").map { p(it) }) + val resolvedSrc = (if (base.isNotBlank() && !src.contains("/")) "$base/$src" else src).substringBefore("#") + return EpubTocItem( + id = e.attr("id"), + title = e.select("navLabel text").first()?.text() ?: "Chapter", + href = resolvedSrc, + children = e.select("> navPoint").map { parsePoint(it) } + ) } - return doc.select("navMap > navPoint").map { p(it) } + + return doc.select("navMap > navPoint").map { parsePoint(it) } } - private fun loadEpubChapter(filePath: String, book: EpubBook, href: String, peeking: Boolean = false): EpubChapter { - val stream = if (filePath.startsWith("content://")) context.contentResolver.openInputStream(Uri.parse(filePath)) ?: throw Exception("EPUB stream error") else File(filePath).inputStream() + private fun loadEpubChapter(filePath: String, book: EpubBook, href: String): EpubChapter { + val stream = if (filePath.startsWith("content://")) { + context.contentResolver.openInputStream(Uri.parse(filePath)) ?: throw Exception("EPUB stream error") + } else { + File(filePath).inputStream() + } + var bytes: ByteArray? = null - ZipInputStream(stream).use { zip -> var e = zip.nextEntry; while (e != null) { if (e.name == href || e.name.endsWith(href)) { bytes = zip.readBytes(); break }; zip.closeEntry(); e = zip.nextEntry } } + ZipInputStream(stream).use { zip -> + var e = zip.nextEntry + while (e != null) { + if (e.name == href || e.name.endsWith(href)) { + bytes = zip.readBytes() + break + } + zip.closeEntry() + e = zip.nextEntry + } + } + val doc = Jsoup.parse(String(bytes ?: throw Exception("No chapter bytes"))) val els = mutableListOf() + doc.select("body").first()?.children()?.forEach { e -> - if (e.tagName() in listOf("p", "div", "h1", "h2", "h3", "h4", "li")) { - if (e.select("img, image").isNotEmpty()) e.children().forEach { /* nested logic simplified */ } - else e.text().trim().let { if (it.length > 1) els.add(ContentElement.Text(it)) } - } else if (e.tagName() == "img") els.add(ContentElement.Image("$filePath#img:${resolveEpubPath(href, e.attr("src"))}", e.attr("alt"))) + when (e.tagName()) { + "p", "div", "h1", "h2", "h3", "h4", "li" -> { + if (e.select("img, image").isEmpty()) { + e.text().trim().let { if (it.length > 1) els.add(ContentElement.Text(it)) } + } + } + "img" -> els.add(ContentElement.Image("$filePath#img:${resolveEpubPath(href, e.attr("src"))}", e.attr("alt"))) + } } - return EpubChapter(href, book.findTocItemByHref(href)?.title, els, book.getNextHref(href), book.getPreviousHref(href)) + + return EpubChapter( + href = href, + title = book.findTocItemByHref(href)?.title, + content = els, + nextHref = book.getNextHref(href), + previousHref = book.getPreviousHref(href) + ) } - private fun resolveEpubPath(base: String, rel: String) = if (rel.startsWith("/")) rel.drop(1) else base.substringBeforeLast("/", "").let { if (it.isNotBlank()) "$it/$rel" else rel } + private fun resolveEpubPath(base: String, rel: String): String { + if (rel.startsWith("/")) return rel.drop(1) + val parent = base.substringBeforeLast("/", "") + return if (parent.isNotBlank()) "$parent/$rel" else rel + } - suspend fun incrementChapterUrl(url: String) = adjustChapterUrl(url, 1) - suspend fun decrementChapterUrl(url: String) = adjustChapterUrl(url, -1) + suspend fun incrementChapterUrl(url: String): String? = adjustChapterUrl(url, 1) + suspend fun decrementChapterUrl(url: String): String? = adjustChapterUrl(url, -1) + private fun adjustChapterUrl(url: String, delta: Int): String? { val patterns = listOf( Regex("(chapter[-_/])(\\d+)", RegexOption.IGNORE_CASE), @@ -354,41 +529,106 @@ class ContentRepository @Inject constructor( ) for (p in patterns) { val m = p.find(url) ?: continue - val n = (m.groupValues.last().toIntOrNull() ?: continue) + delta + val lastGroup = m.groupValues.last() + val n = (lastGroup.toIntOrNull() ?: continue) + delta if (n < 1) return null - return url.replaceRange(m.range, m.value.replace(m.groupValues.last(), n.toString().padStart(m.groupValues.last().length, '0'))) + + val newNum = n.toString().padStart(lastGroup.length, '0') + return url.replaceRange(m.range, m.value.replace(lastGroup, newNum)) } return null } private suspend fun prefetchEpub(path: String): Boolean = withContext(Dispatchers.IO) { - try { - val book = epubBookCache[path] ?: parseEpubFile(path).also { epubBookCache[path] = it } + runCatching { + val book = getEpubBook(path) ?: throw Exception("Failed to load EPUB") val dir = File(epubCacheDir, path.hashCode().toString()).apply { mkdirs() } - val stream = if (path.startsWith("content://")) context.contentResolver.openInputStream(Uri.parse(path)) ?: return@withContext false else File(path).inputStream() - ZipInputStream(stream).use { zip -> var e = zip.nextEntry; while (e != null) { if (!e.isDirectory && isImageFile(e.name)) File(dir, e.name.replace("/", "_")).outputStream().use { zip.copyTo(it) }; zip.closeEntry(); e = zip.nextEntry } } + + val stream = if (path.startsWith("content://")) { + context.contentResolver.openInputStream(Uri.parse(path)) ?: return@withContext false + } else { + File(path).inputStream() + } + + ZipInputStream(stream).use { zip -> + var e = zip.nextEntry + while (e != null) { + if (!e.isDirectory && isImageFile(e.name)) { + val outFile = File(dir, e.name.replace("/", "_")) + outFile.outputStream().use { zip.copyTo(it) } + } + zip.closeEntry() + e = zip.nextEntry + } + } true - } catch (e: Exception) { false } + }.getOrDefault(false) } - private fun isImageFile(f: String) = f.substringAfterLast('.', "").lowercase() in setOf("jpg", "jpeg", "png", "webp") + private fun isImageFile(f: String): Boolean = f.substringAfterLast('.', "").lowercase() in setOf("jpg", "jpeg", "png", "webp") - suspend fun getEpubBook(path: String): EpubBook? = withContext(Dispatchers.IO) { try { epubBookCache[path] ?: parseEpubFile(path).also { epubBookCache[path] = it } } catch (e: Exception) { null } } + suspend fun getEpubBook(path: String): EpubBook? = withContext(Dispatchers.IO) { + runCatching { + epubBookCache[path] ?: parseEpubFile(path).also { epubBookCache[path] = it } + }.getOrNull() + } - suspend fun loadEpubChapterFull(path: String, href: String): EpubChapter? = withContext(Dispatchers.IO) { try { val book = epubBookCache[path] ?: parseEpubFile(path).also { epubBookCache[path] = it }; loadEpubChapter(path, book, href) } catch (e: Exception) { null } } + suspend fun loadEpubChapterFull(path: String, href: String): EpubChapter? = withContext(Dispatchers.IO) { + runCatching { + val book = getEpubBook(path) ?: throw Exception("Failed to load EPUB") + loadEpubChapter(path, book, href) + }.getOrNull() + } suspend fun getEpubImage(url: String): ByteArray? = withContext(Dispatchers.IO) { - try { + runCatching { val parts = url.split("#img:", limit = 2).takeIf { it.size == 2 } ?: return@withContext null - val stream = if (parts[0].startsWith("content://")) context.contentResolver.openInputStream(Uri.parse(parts[0])) ?: return@withContext null else File(parts[0]).inputStream() - ZipInputStream(stream).use { zip -> var e = zip.nextEntry; while (e != null) { if (e.name == parts[1] || e.name.endsWith(parts[1])) return@withContext zip.readBytes(); zip.closeEntry(); e = zip.nextEntry } } + val epubPath = parts[0] + val imgHref = parts[1] + + val stream = if (epubPath.startsWith("content://")) { + context.contentResolver.openInputStream(Uri.parse(epubPath)) ?: return@withContext null + } else { + File(epubPath).inputStream() + } + + ZipInputStream(stream).use { zip -> + var e = zip.nextEntry + while (e != null) { + if (e.name == imgHref || e.name.endsWith(imgHref)) return@runCatching zip.readBytes() + zip.closeEntry() + e = zip.nextEntry + } + } null - } catch (e: Exception) { null } + }.getOrNull() } - suspend fun clearCache(url: String) = withContext(Dispatchers.IO) { if (url.contains("epub")) epubBookCache.remove(url).let { File(epubCacheDir, url.hashCode().toString()).deleteRecursively() } else getCachedFile(url).delete() } + suspend fun clearCache(url: String): Unit = withContext(Dispatchers.IO) { + if (url.contains("epub")) { + epubBookCache.remove(url) + File(epubCacheDir, url.hashCode().toString()).deleteRecursively() + } else { + getCachedFile(url).delete() + } + } - suspend fun clearAllCache() = withContext(Dispatchers.IO) { cacheDir.deleteRecursively(); mediaCacheDir.deleteRecursively(); epubCacheDir.deleteRecursively(); epubBookCache.clear(); cacheDir.mkdirs(); mediaCacheDir.mkdirs(); epubCacheDir.mkdirs(); true } + suspend fun clearAllCache(): Boolean = withContext(Dispatchers.IO) { + runCatching { + cacheDir.deleteRecursively() + mediaCacheDir.deleteRecursively() + epubCacheDir.deleteRecursively() + epubBookCache.clear() + cacheDir.mkdirs() + mediaCacheDir.mkdirs() + epubCacheDir.mkdirs() + true + }.getOrDefault(false) + } - suspend fun getCacheSize() = withContext(Dispatchers.IO) { listOf(cacheDir, mediaCacheDir, epubCacheDir).sumOf { it.listFiles()?.sumOf { f -> f.length() } ?: 0L } } + suspend fun getCacheSize(): Long = withContext(Dispatchers.IO) { + listOf(cacheDir, mediaCacheDir, epubCacheDir).sumOf { dir -> + dir.walkTopDown().filter { it.isFile }.sumOf { it.length() } + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/ExploreRepository.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/ExploreRepository.kt index 168c285..34e4bf6 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/ExploreRepository.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/ExploreRepository.kt @@ -24,51 +24,51 @@ class ExploreRepository @Inject constructor( fun getAllSources(): List = sources.toList() - suspend fun getPopularNovels(page: Int = 1, sourceName: String? = null, tags: List = emptyList()): List = coroutineScope { - val activeSources = if (sourceName == null) sources else sources.filter { it.name == sourceName } + suspend fun getPopularNovels( + page: Int = 1, + sourceName: String? = null, + tags: List = emptyList() + ): List = coroutineScope { + val activeSources = filterSources(sourceName) - activeSources.map { source -> - async { - try { - source.getPopularNovels(page, tags) - } catch (e: Exception) { - emptyList() - } - } - }.awaitAll().flatten().let { if (sourceName == null) it.shuffled() else it } + val results = activeSources.map { source -> + async { runCatching { source.getPopularNovels(page, tags) }.getOrDefault(emptyList()) } + }.awaitAll().flatten() + + if (sourceName == null) results.shuffled() else results } suspend fun getTags(sourceName: String?): List = coroutineScope { if (sourceName != null) { sources.find { it.name == sourceName }?.getTags() ?: emptyList() } else { - // Aggregate tags from all sources and deduplicate - sources.map { async { it.getTags() } }.awaitAll().flatten().distinct().sorted() + sources.map { async { it.getTags() } } + .awaitAll() + .flatten() + .distinct() + .sorted() } } - suspend fun searchNovels(query: String, page: Int = 1, sourceName: String? = null): List = coroutineScope { - val activeSources = if (sourceName == null) sources else sources.filter { it.name == sourceName } + suspend fun searchNovels( + query: String, + page: Int = 1, + sourceName: String? = null + ): List = coroutineScope { + val activeSources = filterSources(sourceName) activeSources.map { source -> - async { - try { - source.searchNovels(query, page) - } catch (e: Exception) { - emptyList() - } - } + async { runCatching { source.searchNovels(query, page) }.getOrDefault(emptyList()) } }.awaitAll().flatten() } suspend fun getNovelDetails(url: String, sourceName: String): ExploreItem? { val source = sources.find { it.name == sourceName } ?: return null - - return try { - source.getNovelDetails(url) - } catch (e: Exception) { - null - } + return runCatching { source.getNovelDetails(url) }.getOrNull() + } + + private fun filterSources(sourceName: String?): List { + return if (sourceName == null) sources.toList() else sources.filter { it.name == sourceName } } fun getSourceNames(): List = sources.map { it.name } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/HtmlParser.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/HtmlParser.kt index c7b9789..fcb4960 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/HtmlParser.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/HtmlParser.kt @@ -48,129 +48,152 @@ class HtmlParser @Inject constructor() { } fun parse(document: Document, url: String): List { + cleanDocument(document) + + val images = parseImages(document, url) + val paragraphs = parseParagraphs(document) + + val filteredParagraphs = filterParagraphs(paragraphs, document.title()) + + // If it looks like a manga (many images or few paragraphs), return images + if (images.size > 5 || (images.isNotEmpty() && filteredParagraphs.size < 10)) { + return images + } + + if (filteredParagraphs.isEmpty()) { + return if (images.isNotEmpty()) images else emptyList() + } + + return mergeAndFormatParagraphs(filteredParagraphs) + } + + private fun cleanDocument(document: Document): Unit { // Remove advertisements - document.select(""" - .ads-banner, [class*="ads-banner"], [class*="bats-ads"], .ads-responsive, .ads-chapter-bottom, - .bats-detail-bottom-pos-1-detail-bottom-72, .sh-recommend, .cm-info, .next-chapter-img, - [id*="ads-"], [class*="footer-ads"], .ads-contain, .banner-owner, .banner-ads, [class*="ads-contain"] - """.trimIndent().replace("\n", "")).remove() + val adSelectors = listOf( + ".ads-banner", "[class*=\"ads-banner\"]", "[class*=\"bats-ads\"]", ".ads-responsive", + ".ads-chapter-bottom", ".bats-detail-bottom-pos-1-detail-bottom-72", ".sh-recommend", + ".cm-info", ".next-chapter-img", "[id*=\"ads-\"]", "[class*=\"footer-ads\"]", + ".ads-contain", ".banner-owner", ".banner-ads", "[class*=\"ads-contain\"]" + ) + document.select(adSelectors.joinToString(", ")).remove() + // Remove credit/recommend images document.select("img[alt*='credit'], img[alt*='recommend'], img[src*='credit'], img[src*='recommend'], img[alt*='ei0qg'], img[title*='ei0qg']").remove() + } - val title = document.title().takeIf { it.isNotBlank() } - val imagesFromSelectors = mutableListOf() + private fun parseImages(document: Document, url: String): List { val imageElements = document.select(MANGA_IMAGE_SELECTOR) + if (imageElements.isEmpty()) return emptyList() + + val adDomains = listOf( + "yougetwhatyoupayfor.net", "bemobtrcks.com", "xpoker24.com", + "coolgamesunblocked.com", "crazygamesunblocked.net", "abcya3.games", "eos.co.com" + ) - if (imageElements.isNotEmpty()) { - val adDomains = listOf("yougetwhatyoupayfor.net", "bemobtrcks.com", "xpoker24.com", "coolgamesunblocked.com", "crazygamesunblocked.net", "abcya3.games", "eos.co.com") - - imageElements.forEach { element -> - val parentLink = element.parents().firstOrNull { it.tagName() == "a" } - if (parentLink != null) { - val href = parentLink.attr("href") - if (adDomains.any { href.contains(it) } || href.contains("facebook.com") || href.contains("twitter.com")) return@forEach - } + val images = mutableListOf() + imageElements.forEach { element -> + if (isAdImage(element, adDomains)) return@forEach - val src = element.attr("data-src").ifEmpty { element.attr("data-original") }.ifEmpty { element.attr("src") } + val src = element.attr("data-src").ifEmpty { element.attr("data-original") }.ifEmpty { element.attr("src") } + if (src.isBlank() || isThumbnailOrLogo(src, adDomains)) return@forEach - if (src.isNotBlank()) { - if (src.contains("/thumb/") || src.contains("og-image-bat.png") || src.contains("logo") || src.contains("banner") || adDomains.any { src.contains(it) }) return@forEach + val absoluteUrl = resolveImageUrl(src, url) + val width = element.attr("width").toIntOrNull() ?: element.attr("data-width").toIntOrNull() ?: 0 + val height = element.attr("height").toIntOrNull() ?: element.attr("data-height").toIntOrNull() ?: 0 - val absoluteUrl = if (src.startsWith("http")) src else { - val domain = try { URL(url).let { "${it.protocol}://${it.host}" } } catch (e: Exception) { "" } - if (src.startsWith("/")) "$domain$src" else { - val base = url.substringBeforeLast("/") - "$base/$src" - } - } + images.add(ContentElement.Image(url = absoluteUrl, width = width, height = height)) + } - val width = element.attr("width").toIntOrNull() ?: element.attr("data-width").toIntOrNull() ?: 0 - val height = element.attr("height").toIntOrNull() ?: element.attr("data-height").toIntOrNull() ?: 0 + return filterLastMangaImage(images, url) + } - imagesFromSelectors.add(ContentElement.Image( - url = absoluteUrl, - width = width, - height = height - )) - } - } - - if ((url.contains("mangabats.com") || url.contains("manganato.com")) && imagesFromSelectors.size > 5) { - val lastImg = imagesFromSelectors.last() - val firstHost = try { URL(imagesFromSelectors.first().url).host } catch (_: Exception) { "" } - val lastHost = try { URL(lastImg.url).host } catch (_: Exception) { "" } - - if (lastImg.url.contains("recommend") || lastImg.url.contains("banner") || lastImg.url.contains("next") || - lastImg.url.contains("/thumb/") || (lastHost.isNotBlank() && firstHost != lastHost)) { - imagesFromSelectors.removeAt(imagesFromSelectors.size - 1) - } - } + private fun isAdImage(element: Element, adDomains: List): Boolean { + val parentLink = element.parents().firstOrNull { it.tagName() == "a" } ?: return false + val href = parentLink.attr("href") + return adDomains.any { href.contains(it) } || href.contains("facebook.com") || href.contains("twitter.com") + } + + private fun isThumbnailOrLogo(src: String, adDomains: List): Boolean { + return src.contains("/thumb/") || src.contains("og-image-bat.png") || + src.contains("logo") || src.contains("banner") || + adDomains.any { src.contains(it) } + } + + private fun resolveImageUrl(src: String, pageUrl: String): String { + if (src.startsWith("http")) return src + + val domain = runCatching { URL(pageUrl).let { "${it.protocol}://${it.host}" } }.getOrDefault("") + return if (src.startsWith("/")) { + "$domain$src" + } else { + val base = pageUrl.substringBeforeLast("/") + "$base/$src" } + } - val paragraphs = mutableListOf() - val novelElements = document.select(NOVEL_CONTENT_SELECTOR) + private fun filterLastMangaImage(images: MutableList, url: String): List { + if (images.size <= 5 || !(url.contains("mangabats.com") || url.contains("manganato.com"))) { + return images + } + + val lastImg = images.last() + val firstHost = runCatching { URL(images.first().url).host }.getOrDefault("") + val lastHost = runCatching { URL(lastImg.url).host }.getOrDefault("") - if (novelElements.isNotEmpty()) { - novelElements.forEach { element -> - val text = extractTextPreservingLineBreaks(element) - if (text.isNotBlank()) paragraphs.add(text) - } + val isSuspect = lastImg.url.contains("recommend") || lastImg.url.contains("banner") || + lastImg.url.contains("next") || lastImg.url.contains("/thumb/") || + (lastHost.isNotBlank() && firstHost != lastHost) + + if (isSuspect) { + images.removeAt(images.size - 1) } + return images + } - if (paragraphs.isEmpty() && imagesFromSelectors.size <= 5) { - document.select("p").forEach { element -> - val text = extractTextPreservingLineBreaks(element) - if (text.isNotBlank()) paragraphs.add(text) - } + private fun parseParagraphs(document: Document): List { + val novelElements = document.select(NOVEL_CONTENT_SELECTOR) + if (novelElements.isNotEmpty()) { + return novelElements.mapNotNull { extractTextPreservingLineBreaks(it).takeIf { t -> t.isNotBlank() } } } - val filteredParagraphs = paragraphs.filter { raw -> + return document.select("p").mapNotNull { extractTextPreservingLineBreaks(it).takeIf { t -> t.isNotBlank() } } + } + + private fun filterParagraphs(paragraphs: List, title: String?): List { + val cleanTitle = title?.trim()?.lowercase() + return paragraphs.filter { raw -> val p = raw.trim() + val lowerP = p.lowercase() + if (p.isEmpty() || p.matches(DIGIT_ONLY_REGEX) || CHAPTER_CLEANUP_PATTERN.containsMatchIn(p)) return@filter false if (p.length <= 80 && p.contains(CHAPTER_WORD_PATTERN) && p.any { it.isDigit() }) return@filter false - if (title != null && (p.equals(title.trim(), ignoreCase = true) || p.startsWith(title.trim()))) return@filter false + if (cleanTitle != null && (lowerP == cleanTitle || lowerP.startsWith(cleanTitle))) return@filter false true } + } - if (imagesFromSelectors.size > 5 || (imageElements.isNotEmpty() && filteredParagraphs.size < 10)) { - return imagesFromSelectors - } - - if (filteredParagraphs.isEmpty()) { - return if (imagesFromSelectors.isNotEmpty()) imagesFromSelectors else emptyList() - } - + private fun mergeAndFormatParagraphs(paragraphs: List): List { val merged = mutableListOf() var idx = 0 - while (idx < filteredParagraphs.size) { - var cur = filteredParagraphs[idx].trim() + while (idx < paragraphs.size) { + var cur = paragraphs[idx].trim() if (cur.isEmpty()) { idx++; continue } - if (idx + 1 < filteredParagraphs.size) { - val next = filteredParagraphs[idx + 1].trim() - if (next.isNotEmpty()) { - val lastChar = cur.lastOrNull() - val lastW = cur.trim().split(WHITESPACE_REGEX).lastOrNull()?.lowercase() ?: "" - val wordCount = cur.split(WHITESPACE_REGEX).size - - val shouldMerge = (lastChar != null && !SENTENCE_ENDERS.contains(lastChar)) && - (wordCount <= 8 || lastW in CONTINUATION_WORDS || lastW.length <= 4) && - !(cur.contains(':') && next.contains(':')) - - if (shouldMerge) { - cur = (cur + " " + next).replace(MULTIPLE_SPACES_REGEX, " ") - idx += 2 - while (idx < filteredParagraphs.size) { - val peek = filteredParagraphs[idx].trim() - if (peek.isEmpty()) { idx++; continue } - val peekFirst = peek.firstOrNull() - if (peekFirst != null && peekFirst.isUpperCase() && cur.trim().lastOrNull()?.let { SENTENCE_ENDERS.contains(it) } == true) break - cur = (cur + " " + peek).replace(MULTIPLE_SPACES_REGEX, " ") - idx++ - } - merged.add(cur) - continue + if (idx + 1 < paragraphs.size) { + val next = paragraphs[idx + 1].trim() + if (next.isNotEmpty() && shouldMerge(cur, next)) { + cur = (cur + " " + next).replace(MULTIPLE_SPACES_REGEX, " ") + idx += 2 + // Deep merging + while (idx < paragraphs.size) { + val peek = paragraphs[idx].trim() + if (peek.isEmpty()) { idx++; continue } + if (shouldStopMerging(cur, peek)) break + cur = (cur + " " + peek).replace(MULTIPLE_SPACES_REGEX, " ") + idx++ } + merged.add(cur) + continue } } merged.add(cur) @@ -185,6 +208,22 @@ class HtmlParser @Inject constructor() { .map { ContentElement.Text(it) } } + private fun shouldMerge(cur: String, next: String): Boolean { + val lastChar = cur.lastOrNull() ?: return false + val lastWord = cur.trim().split(WHITESPACE_REGEX).lastOrNull()?.lowercase() ?: "" + val wordCount = cur.split(WHITESPACE_REGEX).size + + return !SENTENCE_ENDERS.contains(lastChar) && + (wordCount <= 8 || lastWord in CONTINUATION_WORDS || lastWord.length <= 4) && + !(cur.contains(':') && next.contains(':')) + } + + private fun shouldStopMerging(cur: String, peek: String): Boolean { + val peekFirst = peek.firstOrNull() ?: return true + val curLast = cur.trim().lastOrNull() ?: return true + return peekFirst.isUpperCase() && SENTENCE_ENDERS.contains(curLast) + } + private fun extractTextPreservingLineBreaks(element: Element): String { if (element.selectFirst("br") == null) return element.text() val sb = StringBuilder() diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/LibraryRepository.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/LibraryRepository.kt index c0390ea..e65f0db 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/LibraryRepository.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/LibraryRepository.kt @@ -56,7 +56,7 @@ class LibraryRepository @Inject constructor( } } - private suspend fun migrateIfNecessary() = io { + private suspend fun migrateIfNecessary(): Unit = io { runCatching("Migration failed") { val legacyItems = preferencesManager.loadLibraryItems() if (legacyItems.isNotEmpty()) { @@ -99,11 +99,10 @@ class LibraryRepository @Inject constructor( } suspend fun removeItem(itemId: String): Boolean = runCatching("Failed to remove item", false) { - val item = libraryDao.getItemById(itemId) - if (item != null) { + libraryDao.getItemById(itemId)?.let { item -> libraryDao.deleteItem(item) true - } else false + } ?: false } ?: false suspend fun removeItems(itemIds: Set): Int = runCatching("Failed to remove items", 0) { @@ -117,29 +116,25 @@ class LibraryRepository @Inject constructor( } ?: false suspend fun updateReadingMode(itemId: String, readingMode: ReadingMode): Boolean = runCatching("Failed to update reading mode", false) { - val item = libraryDao.getItemById(itemId) - if (item != null) { - val baseTitle = item.baseTitle + libraryDao.getItemById(itemId)?.let { item -> val allItems = libraryDao.getAllItems().firstOrNull() ?: emptyList() - val itemsToUpdate = allItems.filter { it.baseTitle == baseTitle } - val updatedItems = itemsToUpdate.map { it.copy(readingMode = readingMode) } + val updatedItems = allItems + .filter { it.baseTitle == item.baseTitle } + .map { it.copy(readingMode = readingMode) } libraryDao.insertItems(updatedItems) true - } else false + } ?: false } ?: false suspend fun updateNovelInfo(itemId: String, baseNovelUrl: String, sourceName: String): Boolean = runCatching("Failed to update novel info", false) { - val item = libraryDao.getItemById(itemId) - if (item != null) { - val baseTitle = item.baseTitle + libraryDao.getItemById(itemId)?.let { item -> val allItems = libraryDao.getAllItems().firstOrNull() ?: emptyList() - val itemsToUpdate = allItems.filter { it.baseTitle == baseTitle } - val updatedItems = itemsToUpdate.map { - it.copy(baseNovelUrl = baseNovelUrl, sourceName = sourceName) - } + val updatedItems = allItems + .filter { it.baseTitle == item.baseTitle } + .map { it.copy(baseNovelUrl = baseNovelUrl, sourceName = sourceName) } libraryDao.insertItems(updatedItems) true - } else false + } ?: false } ?: false fun saveProgress( @@ -150,7 +145,7 @@ class LibraryRepository @Inject constructor( lastScrollProgress: Float? = null, lastReadIndex: Int? = null, lastReadOffset: Int? = null - ) { + ): Unit { repositoryScope.launch { updateProgress( itemId, @@ -174,10 +169,9 @@ class LibraryRepository @Inject constructor( lastReadOffset: Int? = null ): Boolean = progressMutex.withLock { runCatching("Failed to update progress", false) { - val item = libraryDao.getItemById(itemId) - if (item != null) { + libraryDao.getItemById(itemId)?.let { item -> val updated = item.copy( - currentChapter = if (currentChapter.isNotBlank()) currentChapter else item.currentChapter, + currentChapter = currentChapter.ifBlank { item.currentChapter }, progress = progress, currentChapterUrl = currentChapterUrl ?: item.currentChapterUrl, lastScrollPosition = lastScrollProgress ?: item.lastScrollPosition, @@ -187,13 +181,12 @@ class LibraryRepository @Inject constructor( ) libraryDao.insertItem(updated) true - } else false + } ?: false } ?: false } suspend fun markAsCurrentlyReading(itemId: String): Boolean = runCatching("Failed to mark as reading", false) { - val item = libraryDao.getItemById(itemId) - if (item != null) { + libraryDao.getItemById(itemId)?.let { item -> if (item.baseTitle.isNotBlank()) { libraryDao.clearUpdatesForBaseTitle(item.baseTitle) } else { @@ -205,7 +198,7 @@ class LibraryRepository @Inject constructor( } ?: false suspend fun getCurrentlyReading(): LibraryItem? = runCatching("Failed to get currently reading") { - libraryDao.getCurrentlyReading() + libraryDao.getCurrentlyReading() ?: libraryDao.getAllItems().firstOrNull()?.firstOrNull() } suspend fun getItemById(itemId: String): LibraryItem? = io { @@ -220,8 +213,8 @@ class LibraryRepository @Inject constructor( val targetItems = items ?: libraryItems.value return targetItems.groupBy { item -> item.baseTitle.ifBlank { item.title } - }.mapValues { (_, items) -> - sortChapters(items) + }.mapValues { (_, group) -> + sortChapters(group) } } @@ -248,16 +241,11 @@ class LibraryRepository @Inject constructor( } private fun parseChapterNumberOrNull(item: LibraryItem): Int? { - val cc = item.currentChapter - if (cc.isNotBlank()) { - val num = TextUtils.extractChapterNumber(cc) - if (num != null) return num + if (item.currentChapter.isNotBlank()) { + TextUtils.extractChapterNumber(item.currentChapter)?.let { return it } } - val titleNum = TextUtils.extractChapterNumber(item.title) - if (titleNum != null) return titleNum - - val urlNum = TextUtils.extractChapterNumber(item.url) - if (urlNum != null) return urlNum + TextUtils.extractChapterNumber(item.title)?.let { return it } + TextUtils.extractChapterNumber(item.url)?.let { return it } return null } @@ -272,7 +260,7 @@ class LibraryRepository @Inject constructor( } } - fun toggleSelection(itemId: String) { + fun toggleSelection(itemId: String): Unit { val currentSelection = _selectedItems.value.toMutableSet() if (itemId in currentSelection) { currentSelection.remove(itemId) @@ -282,35 +270,27 @@ class LibraryRepository @Inject constructor( _selectedItems.value = currentSelection } - fun selectItem(itemId: String) { - val currentSelection = _selectedItems.value.toMutableSet() - currentSelection.add(itemId) - _selectedItems.value = currentSelection + fun selectItem(itemId: String): Unit { + _selectedItems.update { it + itemId } } - fun deselectItem(itemId: String) { - val currentSelection = _selectedItems.value.toMutableSet() - currentSelection.remove(itemId) - _selectedItems.value = currentSelection + fun deselectItem(itemId: String): Unit { + _selectedItems.update { it - itemId } } - fun selectItems(itemIds: List) { - val currentSelection = _selectedItems.value.toMutableSet() - currentSelection.addAll(itemIds) - _selectedItems.value = currentSelection + fun selectItems(itemIds: List): Unit { + _selectedItems.update { it + itemIds } } - fun deselectItems(itemIds: List) { - val currentSelection = _selectedItems.value.toMutableSet() - currentSelection.removeAll(itemIds) - _selectedItems.value = currentSelection + fun deselectItems(itemIds: List): Unit { + _selectedItems.update { it - itemIds } } - fun selectAll() { + fun selectAll(): Unit { _selectedItems.value = libraryItems.value.map { it.id }.toSet() } - fun clearSelection() { + fun clearSelection(): Unit { _selectedItems.value = emptySet() } @@ -319,7 +299,7 @@ class LibraryRepository @Inject constructor( return libraryItems.value.filter { it.id in selectedIds } } - suspend fun clearLibrary() = io { + suspend fun clearLibrary(): Unit = io { runCatching("Failed to clear library") { _selectedItems.value = emptySet() val all = libraryDao.getAllItems().firstOrNull() ?: emptyList() @@ -327,7 +307,7 @@ class LibraryRepository @Inject constructor( } } - suspend fun refreshLibraryUpdates(exploreRepository: ExploreRepository) = io { + suspend fun refreshLibraryUpdates(exploreRepository: ExploreRepository): Unit = io { runCatching("Refresh updates failed") { val allItems = libraryDao.getAllItems().firstOrNull() ?: emptyList() val groupedItems = getGroupedByTitle(allItems) @@ -359,14 +339,15 @@ class LibraryRepository @Inject constructor( } suspend fun clearUpdateIndicator(itemId: String): Boolean = runCatching("Failed to clear update indicator", false) { - val item = libraryDao.getItemById(itemId) - if (item != null && item.hasUpdates) { - libraryDao.insertItem(item.copy(hasUpdates = false)) - true - } else false + libraryDao.getItemById(itemId)?.let { item -> + if (item.hasUpdates) { + libraryDao.insertItem(item.copy(hasUpdates = false)) + true + } else false + } ?: false } ?: false - fun toggleSourceExpansion(sourceName: String) { + fun toggleSourceExpansion(sourceName: String): Unit { val current = _collapsedSources.value.toMutableSet() if (current.contains(sourceName)) { current.remove(sourceName) diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt index bd8fdc4..065f237 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/SummaryService.kt @@ -28,24 +28,18 @@ class SummaryService @Inject constructor( /** * Initialize the SmolLM model (lazy loading) - * Downloads a small model suitable for summarization if needed */ suspend fun initialize(): Result = withContext(Dispatchers.IO) { - if (isInitialized) { - return@withContext Result.success(Unit) - } + if (isInitialized) return@withContext Result.success(Unit) if (isInitializing) { - // Wait for existing initialization - while (isInitializing) { - kotlinx.coroutines.delay(100) - } + while (isInitializing) kotlinx.coroutines.delay(100) return@withContext if (isInitialized) Result.success(Unit) else Result.failure(Exception("Initialization failed")) } isInitializing = true - try { + runCatching { Log.d(TAG, "Downloading and ensuring model via LLMEdgeManager for chapter summarization") val modelId = "unsloth/Qwen3-0.6B-GGUF" @@ -58,135 +52,94 @@ class SummaryService @Inject constructor( preferSystemDownloader = true ) - Log.d(TAG, "Model ready: ${'$'}{downloadedFile.name} (path=${'$'}{downloadedFile.absolutePath})") + Log.d(TAG, "Model ready: ${downloadedFile.name} (path=${downloadedFile.absolutePath})") modelFile = downloadedFile - // Prefer conservative performance mode for stability on mobile devices LLMEdgeManager.preferPerformanceMode = false isInitialized = true isInitializing = false - - Result.success(Unit) - } catch (e: Exception) { + Unit + }.onFailure { e -> Log.e(TAG, "Failed to initialize SmolLM", e) isInitializing = false - Result.failure(e) } } /** * Generate a summary for the given chapter content - * @param chapterTitle The chapter title (optional, for context) - * @param content The chapter content (list of paragraphs) - * @return Summary text or error message */ suspend fun generateSummary( chapterTitle: String?, content: List, onProgress: ((String) -> Unit)? = null ): Result = withContext(Dispatchers.Default) { - try { - // Ensure initialization - val initResult = initialize() - if (initResult.isFailure) { - return@withContext Result.failure(initResult.exceptionOrNull() ?: Exception("Initialization failed")) - } + runCatching { + initialize().getOrThrow() - // LLMEdgeManager will handle model loading/caching; ensure we have a model path - val currentModelPath = modelFile?.absolutePath - - // Smart content selection for better summaries - // Reduced to 300 words to ensure we stay well within token/context limits val selectedContent = selectKeyContent(content, maxWords = 300) + val prompt = buildPrompt(chapterTitle, selectedContent) - val prompt = buildString { - append("Read this chapter excerpt and provide a concise summary focusing on:\n") - append("- Main plot developments\n") - append("- Key character actions and decisions\n") - append("- Important events or revelations\n\n") - if (!chapterTitle.isNullOrBlank()) { - append("Chapter title: ${'$'}{chapterTitle}\n\n") - } - append("Chapter text:\n") - append(selectedContent) - append("\n\nProvide a 3-4 sentence summary:") - } - - // Log token estimation - val estimatedTokens = (selectedContent.length / 4) + (prompt.length / 4) + 200 // rough estimate - Log.d(TAG, "Generating summary (${selectedContent.split(Regex("\\s+")).size} words, ${selectedContent.length} chars, ~${estimatedTokens} tokens)") - - var summary: String - try { - val params = LLMEdgeManager.TextGenerationParams( - prompt = prompt, - systemPrompt = """You are a concise chapter summarizer. - |Your task is to read novel chapters and create brief, informative summaries. - |Focus on: main plot points, key character actions, and important events. - |Keep summaries to 2-3 sentences. Be factual and avoid speculation.""".trimMargin(), - modelId = "unsloth/Qwen3-0.6B-GGUF", - modelFilename = modelFile?.name ?: "Qwen3-0.6B-Q4_K_M.gguf", - modelPath = currentModelPath, - temperature = 0.3f, - maxTokens = 256, - thinkingMode = SmolLM.ThinkingMode.DISABLED, - reasoningBudget = 0 - ) - - summary = withContext(Dispatchers.IO) { LLMEdgeManager.generateText(context, params, onProgress) } - } catch (e: IllegalStateException) { - if (e.message?.contains("context size reached") == true) { - Log.w(TAG, "Context size reached, trying with shorter content") - // Try with much shorter content - 300 words - val shorterContent = selectKeyContent(content, maxWords = 300) - val shorterPrompt = buildString { - append("Summarize this chapter excerpt in 2-3 sentences:\n\n") - append(shorterContent) - append("\n\nSummary:") - } - Log.d(TAG, "Retry: ${shorterContent.split(Regex("\\s+")).size} words, ${shorterContent.length} chars") - val params = LLMEdgeManager.TextGenerationParams( - prompt = shorterPrompt, - systemPrompt = "You are a concise chapter summarizer.", - modelId = "unsloth/Qwen3-0.6B-GGUF", - modelFilename = modelFile?.name ?: "Qwen3-0.6B-Q4_K_M.gguf", - modelPath = currentModelPath, - temperature = 0.3f, - thinkingMode = SmolLM.ThinkingMode.DISABLED, - reasoningBudget = 0 - ) - summary = withContext(Dispatchers.IO) { LLMEdgeManager.generateText(context, params, onProgress) } - Log.d(TAG, "Generated summary with shorter content") - } else { - throw e - } - } - - Log.d(TAG, "Summary generated: ${summary.take(100)}...") + Log.d(TAG, "Generating summary (${selectedContent.split(Regex("\\s+")).size} words, ~${(selectedContent.length + prompt.length) / 4 + 200} tokens)") - Result.success(summary.trim()) - } catch (e: Exception) { + generateWithRetry(prompt, selectedContent, content, onProgress) + }.onFailure { e -> Log.e(TAG, "Failed to generate summary", e) - Result.failure(e) + } + } + + private fun buildPrompt(chapterTitle: String?, content: String): String = buildString { + append("Read this chapter excerpt and provide a concise summary focusing on:\n") + append("- Main plot developments\n- Key character actions\n- Important events\n\n") + if (!chapterTitle.isNullOrBlank()) append("Chapter title: $chapterTitle\n\n") + append("Chapter text:\n$content\n\nProvide a 3-4 sentence summary:") + } + + private suspend fun generateWithRetry( + prompt: String, + selectedContent: String, + fullContent: List, + onProgress: ((String) -> Unit)? + ): String { + return try { + generateText(prompt, onProgress) + } catch (e: IllegalStateException) { + if (e.message?.contains("context size reached") == true) { + Log.w(TAG, "Context size reached, retrying with shorter content") + val shorterContent = selectKeyContent(fullContent, maxWords = 200) + val retryPrompt = "Summarize this excerpt in 2-3 sentences:\n\n$shorterContent\n\nSummary:" + generateText(retryPrompt, onProgress) + } else throw e + } + } + + private suspend fun generateText(prompt: String, onProgress: ((String) -> Unit)?): String { + val params = LLMEdgeManager.TextGenerationParams( + prompt = prompt, + systemPrompt = "You are a concise chapter summarizer.", + modelId = "unsloth/Qwen3-0.6B-GGUF", + modelFilename = modelFile?.name ?: "Qwen3-0.6B-Q4_K_M.gguf", + modelPath = modelFile?.absolutePath, + temperature = 0.3f, + maxTokens = 256, + thinkingMode = SmolLM.ThinkingMode.DISABLED + ) + return withContext(Dispatchers.IO) { + LLMEdgeManager.generateText(context, params, onProgress).trim() } } /** - * Generate summary with shorter content (for faster generation) + * Generate summary with shorter content */ suspend fun generateQuickSummary(content: List): Result { - return generateSummary(null, content.take(50)) // Take first 50 paragraphs only + return generateSummary(null, content.take(50)) } /** * Release resources */ - fun release() { - // Cancel any ongoing generation and clear local references - try { - LLMEdgeManager.cancelGeneration() - } catch (_: Throwable) { - } + fun release(): Unit { + runCatching { LLMEdgeManager.cancelGeneration() } modelFile = null isInitialized = false Log.d(TAG, "SummaryService released") @@ -198,72 +151,53 @@ class SummaryService @Inject constructor( private fun selectKeyContent(content: List, maxWords: Int): String { if (content.isEmpty()) return "" - val totalWords = content.sumOf { it.split(Regex("\\s+")).size } - if (totalWords <= maxWords) { - return content.joinToString("\n\n") + val wordsPerParagraph = content.map { it.split(Regex("\\s+")) } + val totalWords = wordsPerParagraph.sumOf { it.size } + if (totalWords <= maxWords) return content.joinToString("\n\n") + + val scoredParagraphs = content.indices.map { i -> + val words = wordsPerParagraph[i] + ScoredParagraph(i, content[i], words.size, calculateParagraphScore(i, content[i], words.size, content.size)) } - val scoredParagraphs = content.mapIndexed { index, paragraph -> - val words = paragraph.split(Regex("\\s+")) - val wordCount = words.size - - var score = 0.0 - - // Score based on length - score += when { - wordCount in 20..100 -> 2.0 - wordCount in 10..20 -> 1.0 - wordCount > 100 -> 1.5 - else -> 0.5 - } - - // Score based on position (favoring start and end) - val position = index.toDouble() / content.size - score += when { - index < 3 -> 3.0 - index >= content.size - 3 -> 2.5 - position in 0.4..0.6 -> 1.5 - else -> 0.5 - } - - // Dialogue - if (paragraph.contains("\"") || paragraph.contains("'") || - paragraph.contains("said") || paragraph.contains("asked")) score += 1.5 - - // Keywords - val keywordPatterns = listOf( - "suddenly", "realized", "discovered", "decided", "arrived", - "died", "killed", "attacked", "revealed", "secret", - "important", "finally", "however", "but", "although", - "shocked", "surprised", "angry", "happy", "sad" - ) - val lowerParagraph = paragraph.lowercase() - score += keywordPatterns.count { lowerParagraph.contains(it) } * 0.5 - - // Action verbs - val actionVerbs = listOf( - "ran", "fought", "grabbed", "rushed", "jumped", - "fell", "screamed", "whispered", "turned", "opened" - ) - score += actionVerbs.count { lowerParagraph.contains(it) } * 0.3 - - ScoredParagraph(index, paragraph, wordCount, score) + return scoredParagraphs.sortedByDescending { it.score } + .fold(mutableListOf()) { acc, p -> + if (acc.sumOf { it.wordCount } + p.wordCount <= maxWords) acc.add(p) + acc + }.sortedBy { it.index } + .joinToString("\n\n") { it.text } + } + + private fun calculateParagraphScore(index: Int, text: String, wordCount: Int, totalSize: Int): Double { + var score = 0.0 + + score += when (wordCount) { + in 20..100 -> 2.0 + in 10..20 -> 1.0 + in 101..Int.MAX_VALUE -> 1.5 + else -> 0.5 } - val sortedByScore = scoredParagraphs.sortedByDescending { it.score } - val selected = mutableListOf() - var currentWords = 0 + val position = index.toDouble() / totalSize + score += when { + index < 3 -> 3.0 + index >= totalSize - 3 -> 2.5 + position in 0.4..0.6 -> 1.5 + else -> 0.5 + } - for (paragraph in sortedByScore) { - if (currentWords + paragraph.wordCount <= maxWords) { - selected.add(paragraph) - currentWords += paragraph.wordCount - } - if (currentWords >= maxWords * 0.9) break + val lowerText = text.lowercase() + if (text.contains("\"") || text.contains("'") || lowerText.contains("said") || lowerText.contains("asked")) { + score += 1.5 } - selected.sortBy { it.index } - return selected.joinToString("\n\n") { it.text } + val keywords = listOf("suddenly", "realized", "discovered", "decided", "arrived", "died", "killed", "attacked", "revealed", "secret", "important", "finally", "however", "shocked", "surprised") + score += keywords.count { lowerText.contains(it) } * 0.5 + + val verbs = listOf("ran", "fought", "grabbed", "rushed", "jumped", "fell", "screamed", "whispered", "turned", "opened") + score += verbs.count { lowerText.contains(it) } * 0.3 + + return score } /** diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt index 8cd8e86..9c524ed 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/BaseJsoupSource.kt @@ -25,24 +25,12 @@ abstract class BaseJsoupSource : NovelSource { protected fun getDocument(url: String): Document = connect(url).get() protected fun Element.absoluteUrl(attributeKey: String): String { - val value = attr(attributeKey) - return when { - value.isBlank() -> "" - value.startsWith("http") -> value - value.startsWith("//") -> "https:$value" - value.startsWith("/") -> "$baseUrl$value" - else -> "$baseUrl/$value" - } + return resolveUrl(attr(attributeKey)) } protected fun Element.findImage(): String { - return attr("data-src").ifBlank { - attr("data-original").ifBlank { - attr("data-lazy-src").ifBlank { - attr("src") - } - } - } + val candidates = listOf("data-src", "data-original", "data-lazy-src", "src") + return candidates.firstNotNullOfOrNull { attr(it).takeIf { v -> v.isNotBlank() } } ?: "" } protected fun resolveUrl(path: String): String { @@ -51,7 +39,7 @@ abstract class BaseJsoupSource : NovelSource { path.startsWith("http") -> path path.startsWith("//") -> "https:$path" path.startsWith("/") -> "$baseUrl$path" - else -> "$baseUrl/$path" - } + else -> if (path.startsWith(baseUrl)) path else "$baseUrl/$path" + }.replace(Regex("/+"), "/").replace("https:/", "https://").replace("http:/", "http://") } } diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt index 0f6d544..ecb735a 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/MangaBatSource.kt @@ -9,148 +9,121 @@ class MangaBatSource : BaseJsoupSource() { override suspend fun getPopularNovels(page: Int, tags: List): List = io { val url = if (tags.isNotEmpty()) { - val tag = tags.first() // MangaBat only supports one tag in URL - val tagSlug = tag.lowercase().replace(" ", "-") + val tagSlug = tags.first().lowercase().replace(" ", "-") "$baseUrl/genre/$tagSlug?page=$page" } else { "$baseUrl/manga-list/hot-manga?page=$page" } val document = getDocument(url) + val items = parseListElements(document) + + if (items.isEmpty()) { + return@io parseFallbackHomepageLinks(document).distinctBy { it.url } + } + + items.distinctBy { it.url } + } - val items = mutableListOf() - // Add .itemupdate for the main page style list + private fun parseListElements(document: org.jsoup.nodes.Document): List { val elements = document.select(".list-story-item, .item-story, .story_item, .itemupdate, .list-comic-item-wrap") - - elements.forEach { element -> - // Try to find the title element - prioritize h3 a or specific title classes + return elements.mapNotNull { element -> val titleElement = element.select("h3 a, .item-title, .story_name a").first() ?: element.select("a").firstOrNull { it.text().isNotBlank() } - val title = titleElement?.text() ?: "" - val href = titleElement?.attr("href") ?: "" + val title = titleElement?.text() ?: return@mapNotNull null + val href = titleElement.attr("href") + if (href.isBlank()) return@mapNotNull null - // Find the image - it might be in a different link than the title val img = element.select("img").first() - val coverUrl = img?.findImage()?.let { resolveUrl(it) } ?: "" + val coverUrl = img?.findImage()?.let { resolveUrl(it) } - if (title.isNotBlank() && href.isNotBlank()) { - val absoluteUrl = resolveUrl(href) - items.add(ExploreItem( - title = title.trim(), - url = absoluteUrl, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, - source = name - )) - } + ExploreItem( + title = title.trim(), + url = resolveUrl(href), + coverUrl = coverUrl?.ifBlank { null }, + source = name + ) } - - // Generic fallback for homepage (unchanged) - if (items.isEmpty()) { - document.select("a[href*='/manga/']").forEach { link -> - val title = link.text().trim() - val href = link.attr("href") - if (title.length > 5 && !title.contains("Chapter", ignoreCase = true) && items.none { it.url.contains(href) }) { - val absoluteUrl = resolveUrl(href) - val img = link.parent()?.select("img")?.first() ?: link.closest("div")?.select("img")?.first() - val coverUrl = img?.findImage()?.let { resolveUrl(it) } ?: "" + } - items.add(ExploreItem( - title = title, - url = absoluteUrl, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, - source = name - )) - } - } + private fun parseFallbackHomepageLinks(document: org.jsoup.nodes.Document): List { + return document.select("a[href*='/manga/']").mapNotNull { link -> + val title = link.text().trim() + val href = link.attr("href") + if (title.length <= 5 || title.contains("Chapter", ignoreCase = true)) return@mapNotNull null + + val img = link.parent()?.select("img")?.first() ?: link.closest("div")?.select("img")?.first() + val coverUrl = img?.findImage()?.let { resolveUrl(it) } + + ExploreItem( + title = title, + url = resolveUrl(href), + coverUrl = coverUrl?.ifBlank { null }, + source = name + ) } - - items.distinctBy { it.url } } override suspend fun searchNovels(query: String, page: Int): List = io { val encodedQuery = URLEncoder.encode(query.replace(" ", "_"), "UTF-8") - // Correct search URL for Mangabat is /search/story/ val url = "$baseUrl/search/story/$encodedQuery?page=$page" - val document = getDocument(url) - - val items = mutableListOf() - val elements = document.select(".list-story-item, .item-story, .story_item, .itemupdate") - - elements.forEach { element -> - // Try to find the title element - prioritize h3 a or specific title classes - val titleElement = element.select("h3 a, .item-title, .story_name a").first() - ?: element.select("a").firstOrNull { it.text().isNotBlank() } - - val title = titleElement?.text() ?: "" - val href = titleElement?.attr("href") ?: "" - - // Find the image - it might be in a different link than the title - val img = element.select("img").first() - val coverUrl = img?.findImage()?.let { resolveUrl(it) } ?: "" - - if (title.isNotBlank() && href.isNotBlank()) { - val absoluteUrl = resolveUrl(href) - items.add(ExploreItem( - title = title.trim(), - url = absoluteUrl, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, - source = name - )) - } - } - items.distinctBy { it.url } + parseListElements(document).distinctBy { it.url } } override suspend fun getNovelDetails(url: String): ExploreItem = io { val document = connect(url).referrer(url).get() - val title = document.select(".story-info-right h1, h1").text() - // Robust author scraping - var author = document.select(".table-value a[href*='search/author'], .info-author a").text() - if (author.isBlank()) { - author = document.select("li:contains(Author) :not(p)").text() - .ifBlank { document.select("li:contains(Author)").text().replace("Author(s) :", "").replace("Author(s):", "").trim() } - } + val author = extractAuthor(document) + val summary = document.select("#contentBox, .panel-story-info-description, .story-info-description") + .first()?.text()?.replace("Description :", "") + ?.replace(Regex(".*summary: ", RegexOption.IGNORE_CASE), "")?.trim() - // Improved summary selector - val summaryElement = document.select("#contentBox, .panel-story-info-description, .story-info-description").first() - val summary = summaryElement?.text()?.replace("Description :", "")?.replace(Regex(".*summary: ", RegexOption.IGNORE_CASE), "")?.trim() - - // Improved coverUrl selector using OpenGraph or specific image alt - val coverImg = document.select(".info-image img, .story-info-left img, .manga-info-pic img").first() - val coverUrl = document.select("meta[property='og:image']").attr("content") - .ifBlank { coverImg?.findImage() ?: "" } - .let { resolveUrl(it) } + val coverUrl = extractCoverUrl(document) val chapters = document.select(".chapter-name, .chapter-list a, .row a[href*='/chapter-']") - val chapterCount = chapters.size - val chapterList = chapters.map { element -> - val chapterUrl = resolveUrl(element.attr("href")) io.aatricks.novelscraper.data.model.ChapterInfo( title = element.text(), - url = chapterUrl + url = resolveUrl(element.attr("href")) ) - }.reversed() // Unify to Ascending (1 to N) - - // First chapter is the first one in the ascending list - val readingUrl = chapterList.firstOrNull()?.url + }.reversed() ExploreItem( title = title, url = url, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, + coverUrl = coverUrl.ifBlank { null }, author = author, summary = summary, - chapterCount = chapterCount, + chapterCount = chapterList.size, source = name, - readingUrl = readingUrl, + readingUrl = chapterList.firstOrNull()?.url, chapters = chapterList ) } + private fun extractAuthor(document: org.jsoup.nodes.Document): String { + val authorByLink = document.select(".table-value a[href*='search/author'], .info-author a").text() + if (authorByLink.isNotBlank()) return authorByLink + + val authorByLabel = document.select("li:contains(Author) :not(p)").text() + if (authorByLabel.isNotBlank()) return authorByLabel + + return document.select("li:contains(Author)").text() + .replace("Author(s) :", "") + .replace("Author(s):", "").trim() + } + + private fun extractCoverUrl(document: org.jsoup.nodes.Document): String { + val ogImage = document.select("meta[property='og:image']").attr("content") + if (ogImage.isNotBlank()) return resolveUrl(ogImage) + + val coverImg = document.select(".info-image img, .story-info-left img, .manga-info-pic img").first() + return resolveUrl(coverImg?.findImage() ?: "") + } + + override suspend fun getTags(): List = listOf( "Action", "Adult", "Adventure", "Comedy", "Cooking", "Doujinshi", "Drama", "Ecchi", "Fantasy", "Gender bender", "Harem", "Historical", "Horror", "Isekai", "Josei", "Manhua", "Manhwa", diff --git a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt index 04553f1..59a2b81 100644 --- a/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt +++ b/app/src/main/java/io/aatricks/novelscraper/data/repository/source/NovelFireSource.kt @@ -27,7 +27,7 @@ class NovelFireSource : BaseJsoupSource() { override suspend fun getPopularNovels(page: Int, tags: List): List = io { val url = if (tags.isNotEmpty()) { - val tag = tags.first() // Use first tag for now + val tag = tags.first() val tagSlug = tag.lowercase().replace(" ", "-") "$baseUrl/genre-$tagSlug/sort-popular/status-all/all-novel?page=$page" } else { @@ -42,20 +42,18 @@ class NovelFireSource : BaseJsoupSource() { val rawTitle = link.text() val title = cleanNovelTitle(rawTitle) val href = link.attr("href") - // Avoid empty titles or "Read Now" links if they exist + if (title.isNotBlank() && !title.equals("Read Now", ignoreCase = true) && !title.contains("Chapter", ignoreCase = true)) { - // Try to find image nearby val parent = link.closest(".novel-item, .item, .book-item") ?: link.parent()?.parent() val img = parent?.select("img")?.first() val coverUrl = img?.findImage()?.let { resolveUrl(it) } ?: "" - // Deduplicate by URL val absoluteUrl = resolveUrl(href) if (items.none { it.url == absoluteUrl }) { items.add(ExploreItem( title = title, url = absoluteUrl, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, + coverUrl = coverUrl.ifBlank { null }, source = name )) } @@ -68,13 +66,13 @@ class NovelFireSource : BaseJsoupSource() { val encodedQuery = URLEncoder.encode(query, "UTF-8") val url = "$baseUrl/ajax/searchLive?inputContent=$encodedQuery" - try { + runCatching { val response = connect(url) .ignoreContentType(true) .execute() .body() - val json = org.json.JSONObject(response) + val json = JSONObject(response) val data = json.getJSONArray("data") val items = mutableListOf() @@ -95,7 +93,7 @@ class NovelFireSource : BaseJsoupSource() { )) } items - } catch (e: Exception) { + }.getOrElse { val fallbackUrl = "$baseUrl/genre-all/sort-popular/status-all/all-novel?keyword=$encodedQuery&page=$page" val document = getDocument(fallbackUrl) @@ -117,7 +115,7 @@ class NovelFireSource : BaseJsoupSource() { items.add(ExploreItem( title = title, url = absoluteUrl, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, + coverUrl = coverUrl.ifBlank { null }, source = name )) } @@ -129,127 +127,117 @@ class NovelFireSource : BaseJsoupSource() { override suspend fun getNovelDetails(url: String): ExploreItem = io { val document = getDocument(url) - val rawTitle = document.select("h1, .novel-title").first()?.text() ?: "Unknown Title" val title = cleanNovelTitle(rawTitle) val author = document.select(".author a, .author").first()?.text() - - val summaryElement = document.select(".summary .content p, .summary .content, #summary, .description").first() - val summary = if (summaryElement != null) { - document.select(".summary .content p").joinToString("\n\n") { it.text() } - .ifEmpty { summaryElement.text() } - } else null + val summary = extractSummary(document) val coverImg = document.select(".fixed-img .cover img, .book-cover img, .novel-cover img").first() val coverUrl = resolveUrl(coverImg?.findImage() ?: "") val infoText = document.text() - val chapterCountRegex = Regex("(\\d+)\\s*Chapters", RegexOption.IGNORE_CASE) - val chapterCount = chapterCountRegex.find(infoText)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + val chapterCount = extractChapterCount(infoText) + val rank = Regex("RANK\\s+(\\d+)", RegexOption.IGNORE_CASE).find(infoText)?.groupValues?.get(1) + val rating = Regex("Average score is\\s+([0-9.]+)", RegexOption.IGNORE_CASE).find(infoText)?.groupValues?.get(1) - val rankRegex = Regex("RANK\\s+(\\d+)", RegexOption.IGNORE_CASE) - val rank = rankRegex.find(infoText)?.groupValues?.get(1) + val chaptersUrl = getChaptersUrl(url, document) + val firstPageDoc = runCatching { getDocument(chaptersUrl) }.getOrDefault(document) - val ratingRegex = Regex("Average score is\\s+([0-9.]+)", RegexOption.IGNORE_CASE) - val rating = ratingRegex.find(infoText)?.groupValues?.get(1) + val allChapters = mutableListOf() + allChapters.addAll(parseChapters(firstPageDoc)) - val chaptersPageHref = document.select("a[href$='/chapters']").attr("href") - val chaptersUrl = if (chaptersPageHref.isNotBlank()) { - resolveUrl(chaptersPageHref) - } else { - if (url.endsWith("/chapters")) url else "$url/chapters" + val maxPage = extractMaxPage(firstPageDoc, chaptersUrl) + if (maxPage > 1) { + allChapters.addAll(loadAdditionalChapterPages(chaptersUrl, maxPage)) } - val firstPageDoc = try { - getDocument(chaptersUrl) - } catch (e: Exception) { - document - } + val readingUrl = allChapters.firstOrNull()?.url ?: resolveUrl(document.select("a:contains(Read Now)").attr("href")).ifBlank { url } - val allChapters = mutableListOf() + ExploreItem( + title = title, + url = url, + coverUrl = coverUrl.ifBlank { null }, + author = author, + summary = summary, + chapterCount = chapterCount, + rank = rank, + rating = rating, + source = name, + readingUrl = readingUrl, + chapters = allChapters + ) + } - fun parseChapters(doc: org.jsoup.nodes.Document): List { - return doc.select(".chapter-list li a, ul.chapters li a, .chapters li a").mapNotNull { element -> - val chapterUrl = resolveUrl(element.attr("href")) + private fun extractSummary(document: org.jsoup.nodes.Document): String? { + val summaryElement = document.select(".summary .content p, .summary .content, #summary, .description").first() + return if (summaryElement != null) { + document.select(".summary .content p").joinToString("\n\n") { it.text() } + .ifEmpty { summaryElement.text() } + } else null + } - var rawTitle = element.attr("title") - if (rawTitle.isBlank()) rawTitle = element.select(".chapter-title").text() - if (rawTitle.isBlank()) rawTitle = element.text() + private fun extractChapterCount(infoText: String): Int { + return Regex("(\\d+)\\s*Chapters", RegexOption.IGNORE_CASE) + .find(infoText)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + } - var cleanTitle = rawTitle - cleanTitle = cleanTitle.replace(Regex("\\d+\\s+(year|month|day|hour|minute|second)s?\\s+ago.*$"), "").trim() + private fun getChaptersUrl(url: String, document: org.jsoup.nodes.Document): String { + val chaptersPageHref = document.select("a[href$='/chapters']").attr("href") + return if (chaptersPageHref.isNotBlank()) { + resolveUrl(chaptersPageHref) + } else { + if (url.endsWith("/chapters")) url else "$url/chapters" + } + } - val leadingNumRegex = Regex("^(\\d+)\\s+(Chapter\\s+\\1.*)") - val match = leadingNumRegex.find(cleanTitle) - if (match != null) { - cleanTitle = match.groupValues[2] - } + private fun parseChapters(doc: org.jsoup.nodes.Document): List { + return doc.select(".chapter-list li a, ul.chapters li a, .chapters li a").mapNotNull { element -> + val chapterUrl = resolveUrl(element.attr("href")) + if (chapterUrl.isBlank()) return@mapNotNull null - if (chapterUrl.isNotBlank()) { - ChapterInfo(title = cleanTitle, url = chapterUrl) - } else null + var rawTitle = element.attr("title").ifBlank { + element.select(".chapter-title").text().ifBlank { element.text() } } - } - allChapters.addAll(parseChapters(firstPageDoc)) + var cleanTitle = rawTitle.replace(Regex("\\d+\\s+(year|month|day|hour|minute|second)s?\\s+ago.*$"), "").trim() + val leadingNumRegex = Regex("^(\\d+)\\s+(Chapter\\s+\\1.*)") + leadingNumRegex.find(cleanTitle)?.let { match -> + cleanTitle = match.groupValues[2] + } - val paginationLinks = firstPageDoc.select("ul.pagination .page-item .page-link") - var maxPage = 1 + ChapterInfo(title = cleanTitle, url = chapterUrl) + } + } + private fun extractMaxPage(doc: org.jsoup.nodes.Document, chaptersUrl: String): Int { + val paginationLinks = doc.select("ul.pagination .page-item .page-link") + var maxPage = 1 paginationLinks.forEach { link -> val pageNum = link.text().toIntOrNull() if (pageNum != null && pageNum > maxPage) { maxPage = pageNum } else { - val href = link.attr("href") - val hrefPage = href.substringAfter("page=").toIntOrNull() + val hrefPage = link.attr("href").substringAfter("page=").toIntOrNull() if (hrefPage != null && hrefPage > maxPage) { maxPage = hrefPage } } } + return maxPage + } - if (maxPage > 1) { - val deferredPages = kotlinx.coroutines.coroutineScope { - (2..maxPage).map { page -> - async { - try { - val pageUrl = if (chaptersUrl.contains("?")) "$chaptersUrl&page=$page" else "$chaptersUrl?page=$page" - val pageDoc = getDocument(pageUrl) - parseChapters(pageDoc) - } catch (e: Exception) { - emptyList() - } - } - } + private suspend fun loadAdditionalChapterPages(chaptersUrl: String, maxPage: Int): List = kotlinx.coroutines.coroutineScope { + (2..maxPage).map { page -> + async { + runCatching { + val pageUrl = if (chaptersUrl.contains("?")) "$chaptersUrl&page=$page" else "$chaptersUrl?page=$page" + parseChapters(getDocument(pageUrl)) + }.getOrDefault(emptyList()) } - allChapters.addAll(deferredPages.awaitAll().flatten()) - } - - val readingUrl = if (allChapters.isNotEmpty()) { - allChapters.first().url - } else { - val readNowHref = document.select("a:contains(Read Now)").attr("href") - if (readNowHref.isNotBlank()) { - resolveUrl(readNowHref) - } else url - } - - ExploreItem( - title = title, - url = url, - coverUrl = if (coverUrl.isBlank()) null else coverUrl, - author = author, - summary = summary, - chapterCount = chapterCount, - rank = rank, - rating = rating, - source = name, - readingUrl = readingUrl, - chapters = allChapters - ) + }.awaitAll().flatten() } + override suspend fun getTags(): List = listOf( "Action", "Adventure", "Comedy", "Drama", "Ecchi", "Fantasy", "Gender Bender", "Harem", "Historical", "Horror", "Josei", "Martial Arts", "Mature", "Mystery", "Psychological", "Romance", "School Life", diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt b/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt index d3d78fc..52ee297 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/screens/LibraryDrawerContent.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* @@ -39,7 +40,7 @@ fun LibraryDrawerContent( navController: NavController, onOpenFilePicker: () -> Unit, onCloseDrawer: () -> Unit -) { +): Unit { val libraryUiState by libraryViewModel.uiState.collectAsState() val readerUiState by readerViewModel.uiState.collectAsState() val searchQuery by libraryViewModel.searchQuery.collectAsState() @@ -60,447 +61,561 @@ fun LibraryDrawerContent( .background(MaterialTheme.colorScheme.surface) .padding(16.dp) ) { - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Library", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Row { - IconButton(onClick = { - onCloseDrawer() - navController.navigate(ExploreRoute) - }) { - Icon( - imageVector = Icons.Default.Image, - contentDescription = "Explore", - tint = MaterialTheme.colorScheme.onSurface - ) - } - IconButton(onClick = { isAddSectionVisible = !isAddSectionVisible }) { - Icon( - imageVector = if (isAddSectionVisible) Icons.Filled.Close else Icons.Filled.Add, - contentDescription = if (isAddSectionVisible) "Close Add" else "Add Novel", - tint = MaterialTheme.colorScheme.onSurface - ) - } + LibraryHeader( + isAddVisible = isAddSectionVisible, + onToggleAdd = { isAddSectionVisible = !isAddSectionVisible }, + onExploreClick = { + onCloseDrawer() + navController.navigate(ExploreRoute) } - } + ) androidx.compose.animation.AnimatedVisibility(visible = isAddSectionVisible) { - Column { - OutlinedTextField( - value = urlInput, - onValueChange = { urlInput = it }, - label = { Text("Novel URL") }, - placeholder = { Text("Enter novel URL...") }, - modifier = Modifier.fillMaxWidth(), - trailingIcon = { - if (urlInput.isNotEmpty()) { - IconButton(onClick = { urlInput = "" }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = "Clear URL" - ) - } - } - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), - keyboardActions = KeyboardActions(onGo = { - if (urlInput.isNotBlank()) { - libraryViewModel.fetchAndAdd(urlInput) - urlInput = "" - isAddSectionVisible = false - } - }), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - cursorColor = MaterialTheme.colorScheme.primary - ), - singleLine = true - ) + AddNovelSection( + urlInput = urlInput, + onUrlChange = { urlInput = it }, + onAddClick = { + libraryViewModel.fetchAndAdd(urlInput) + urlInput = "" + isAddSectionVisible = false + }, + onOpenPdfClick = onOpenFilePicker + ) + } - Spacer(modifier = Modifier.height(12.dp)) + SearchLibraryField( + query = searchQuery, + onQueryChange = { libraryViewModel.updateSearchQuery(it) } + ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Button( - onClick = { - if (urlInput.isNotBlank()) { - libraryViewModel.fetchAndAdd(urlInput) - urlInput = "" - isAddSectionVisible = false - } - }, - modifier = Modifier.weight(1f).height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary - ), - enabled = urlInput.isNotBlank(), - shape = RoundedCornerShape(24.dp) - ) { - Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text("Add", fontWeight = FontWeight.SemiBold) - } + Spacer(modifier = Modifier.height(16.dp)) - Button( - onClick = { onOpenFilePicker() }, - modifier = Modifier.weight(1f).height(48.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), - shape = RoundedCornerShape(24.dp) - ) { - Text("Open PDF", fontWeight = FontWeight.SemiBold) - } - } - Spacer(modifier = Modifier.height(12.dp)) + if (libraryUiState.isSelectionMode) { + SelectionActions( + onDelete = { libraryViewModel.removeSelectedItems() }, + onCancel = { libraryViewModel.clearSelection() } + ) + } + + HorizontalDivider(color = Color.DarkGray) + Spacer(modifier = Modifier.height(8.dp)) + + if (libraryUiState.items.isEmpty()) { + EmptyLibraryState() + } else { + LibraryItemList( + uiState = libraryUiState, + readerUiState = readerUiState, + summaryUiState = summaryUiState, + libraryViewModel = libraryViewModel, + readerViewModel = readerViewModel, + summaryViewModel = summaryViewModel, + onCloseDrawer = onCloseDrawer + ) + } + } +} + +@Composable +private fun LibraryHeader( + isAddVisible: Boolean, + onToggleAdd: () -> Unit, + onExploreClick: () -> Unit +): Unit { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Library", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Row { + IconButton(onClick = onExploreClick) { + Icon( + imageVector = Icons.Default.Image, + contentDescription = "Explore", + tint = MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onToggleAdd) { + Icon( + imageVector = if (isAddVisible) Icons.Filled.Close else Icons.Filled.Add, + contentDescription = if (isAddVisible) "Close Add" else "Add Novel", + tint = MaterialTheme.colorScheme.onSurface + ) } } + } +} +@Composable +private fun AddNovelSection( + urlInput: String, + onUrlChange: (String) -> Unit, + onAddClick: () -> Unit, + onOpenPdfClick: () -> Unit +): Unit { + Column { OutlinedTextField( - value = searchQuery, - onValueChange = { libraryViewModel.updateSearchQuery(it) }, - placeholder = { Text("Search library...") }, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + value = urlInput, + onValueChange = onUrlChange, + label = { Text("Novel URL") }, + placeholder = { Text("Enter novel URL...") }, + modifier = Modifier.fillMaxWidth(), trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { libraryViewModel.updateSearchQuery("") }) { - Icon(Icons.Default.Close, contentDescription = "Clear") + if (urlInput.isNotEmpty()) { + IconButton(onClick = { onUrlChange("") }) { + Icon(imageVector = Icons.Filled.Close, contentDescription = "Clear URL") } } }, - modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go), + keyboardActions = KeyboardActions(onGo = { if (urlInput.isNotBlank()) onAddClick() }), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline + cursorColor = MaterialTheme.colorScheme.primary ), singleLine = true ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) - if (libraryUiState.isSelectionMode) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onAddClick, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), + enabled = urlInput.isNotBlank(), + shape = RoundedCornerShape(24.dp) ) { - Button( - onClick = { libraryViewModel.removeSelectedItems() }, - modifier = Modifier.weight(1f).height(48.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color.Red), - shape = RoundedCornerShape(24.dp) - ) { - Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text("Delete", color = Color.White, fontWeight = FontWeight.SemiBold) - } - Button( - onClick = { libraryViewModel.clearSelection() }, - modifier = Modifier.weight(1f).height(48.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color.Gray), - shape = RoundedCornerShape(24.dp) - ) { - Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Text("Cancel", color = Color.White, fontWeight = FontWeight.SemiBold) - } + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Add", fontWeight = FontWeight.SemiBold) + } + + Button( + onClick = onOpenPdfClick, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), + shape = RoundedCornerShape(24.dp) + ) { + Text("Open PDF", fontWeight = FontWeight.SemiBold) } - Spacer(modifier = Modifier.height(12.dp)) } + Spacer(modifier = Modifier.height(12.dp)) + } +} - HorizontalDivider(color = Color.DarkGray) - Spacer(modifier = Modifier.height(8.dp)) +@Composable +private fun SearchLibraryField( + query: String, + onQueryChange: (String) -> Unit +): Unit { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search library...") }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon(Icons.Default.Close, contentDescription = "Clear") + } + } + }, + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline + ), + singleLine = true + ) +} - if (libraryUiState.items.isEmpty()) { - EmptyLibraryState() - } else { - val contentRepository = readerViewModel.contentRepository +@Composable +private fun SelectionActions( + onDelete: () -> Unit, + onCancel: () -> Unit +): Unit { + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = onDelete, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.Red), + shape = RoundedCornerShape(24.dp) + ) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Delete", color = Color.White, fontWeight = FontWeight.SemiBold) + } + Button( + onClick = onCancel, + modifier = Modifier.weight(1f).height(48.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color.Gray), + shape = RoundedCornerShape(24.dp) + ) { + Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Text("Cancel", color = Color.White, fontWeight = FontWeight.SemiBold) + } + } +} - val expandedNovelState = remember { mutableStateMapOf() } - val showFullChaptersState = remember { mutableStateMapOf() } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun LibraryItemList( + uiState: LibraryViewModel.LibraryUiState, + readerUiState: ReaderViewModel.ReaderUiState, + summaryUiState: SummaryViewModel.SummaryUiState, + libraryViewModel: LibraryViewModel, + readerViewModel: ReaderViewModel, + summaryViewModel: SummaryViewModel, + onCloseDrawer: () -> Unit +): Unit { + val expandedNovelState = remember { mutableStateMapOf() } + val showFullChaptersState = remember { mutableStateMapOf() } + val scope = rememberCoroutineScope() - val groupedBySource = libraryUiState.groupedBySource + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + uiState.groupedBySource.forEach { (sourceName, novels) -> + val isSourceExpanded = !uiState.collapsedSources.contains(sourceName) + + item(key = "source_$sourceName") { + SourceHeader( + name = sourceName, + isExpanded = isSourceExpanded, + onClick = { libraryViewModel.toggleSourceExpansion(sourceName) } + ) + } - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - groupedBySource.forEach { sourceEntry -> - val sourceName = sourceEntry.key - val novels = sourceEntry.value - - val isSourceExpanded = !libraryUiState.collapsedSources.contains(sourceName) - - item(key = "source_$sourceName") { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { libraryViewModel.toggleSourceExpansion(sourceName) } - .padding(vertical = 8.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = sourceName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.weight(1f) + if (isSourceExpanded) { + novels.forEach { (groupTitle, chapterItems) -> + item(key = groupTitle) { + val firstItem = chapterItems.firstOrNull() + if (firstItem?.contentType == ContentType.EPUB) { + EpubItemCard( + item = firstItem, + contentRepository = readerViewModel.contentRepository, + readerViewModel = readerViewModel, + libraryViewModel = libraryViewModel, + onCloseDrawer = onCloseDrawer ) - Icon( - imageVector = if (isSourceExpanded) Icons.Filled.ArrowDropDown else Icons.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant + } else { + NovelGroupCard( + title = groupTitle, + items = chapterItems, + uiState = uiState, + readerUiState = readerUiState, + summaryUiState = summaryUiState, + isExpanded = expandedNovelState.getOrPut(groupTitle) { false }, + showFullChapters = showFullChaptersState[groupTitle] ?: false, + onToggleExpand = { expandedNovelState[groupTitle] = !(expandedNovelState[groupTitle] ?: false) }, + onToggleShowFull = { showFullChaptersState[groupTitle] = !(showFullChaptersState[groupTitle] ?: false) }, + libraryViewModel = libraryViewModel, + readerViewModel = readerViewModel, + summaryViewModel = summaryViewModel, + onCloseDrawer = onCloseDrawer ) } } + } + } + } + } +} - if (isSourceExpanded) { - novels.forEach { novelEntry -> - val groupTitle = novelEntry.key - val chapterItems = novelEntry.value - - item(key = groupTitle) { - val firstItem = chapterItems.firstOrNull() - if (firstItem != null && firstItem.contentType == ContentType.EPUB) { - EpubItemCard( - item = firstItem, - contentRepository = contentRepository, - readerViewModel = readerViewModel, - libraryViewModel = libraryViewModel, - onCloseDrawer = onCloseDrawer - ) - } else { - val isExpanded = expandedNovelState.getOrPut(groupTitle) { false } - val isGroupSelected = chapterItems.all { it.id in libraryUiState.selectedIds } - val isSelectionMode = libraryUiState.isSelectionMode - val showFullChapters = showFullChaptersState[groupTitle] ?: false - - Card( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp) - .clip(RoundedCornerShape(10.dp)), - colors = CardDefaults.cardColors( - containerColor = if (isGroupSelected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(modifier = Modifier.weight(1f)) { - Column( - modifier = Modifier - .fillMaxWidth() - .combinedClickable( - onClick = { - if (isSelectionMode) { - libraryViewModel.toggleGroupSelection(groupTitle) - } else { - val current = chapterItems.find { it.isCurrentlyReading } - ?: chapterItems.maxByOrNull { it.lastRead } - ?: chapterItems.first() - val loadUrl = if (current.currentChapterUrl.isNotBlank()) current.currentChapterUrl else current.url - readerViewModel.loadContent(loadUrl, current.id) - libraryViewModel.markAsCurrentlyReading(current.id) - onCloseDrawer() - } - }, - onLongClick = { libraryViewModel.toggleGroupSelection(groupTitle) } - ) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val hasUpdates = chapterItems.any { it.hasUpdates } - val lastItem = chapterItems.lastOrNull() - val isCaughtUp = lastItem?.let { it.isCurrentlyReading || it.progress > 0 } ?: false - - Text( - text = groupTitle, - style = MaterialTheme.typography.titleMedium, - color = if (hasUpdates && isCaughtUp) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, - modifier = Modifier.weight(1f, fill = false) - ) - - if (hasUpdates) { - Spacer(modifier = Modifier.width(8.dp)) - Badge( - containerColor = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.clickable { - val lastItem = chapterItems.lastOrNull() - if (lastItem != null) { - if (lastItem.baseNovelUrl.isNotBlank() && lastItem.sourceName.isNotBlank()) { - libraryViewModel.openNewChapter( - baseTitle = groupTitle, - baseNovelUrl = lastItem.baseNovelUrl, - sourceName = lastItem.sourceName, - onChapterLoaded = { url, id -> - readerViewModel.loadContent(url, id) - libraryViewModel.markAsCurrentlyReading(id) - onCloseDrawer() - } - ) - } else { - val loadUrl = if (lastItem.currentChapterUrl.isNotBlank()) lastItem.currentChapterUrl else lastItem.url - readerViewModel.loadContent(loadUrl, lastItem.id) - libraryViewModel.markAsCurrentlyReading(lastItem.id) - onCloseDrawer() - } - } - } - ) { - Text( - text = "NEW", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiary - ) - } - } - } - if (!isExpanded) { - val current = chapterItems.find { it.isCurrentlyReading } ?: chapterItems.maxByOrNull { it.lastRead } ?: chapterItems.first() - Text( - text = current.currentChapter.ifBlank { "Chapter 1" }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (current.isCurrentlyReading) { - Spacer(modifier = Modifier.height(4.dp)) - LinearProgressIndicator( - progress = { readerUiState.scrollProgress / 100f }, - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant - ) - } - } - } - } - IconButton(onClick = { expandedNovelState[groupTitle] = !isExpanded }) { - Icon( - imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.Filled.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface - ) - } - } - - if (isExpanded) { - Spacer(modifier = Modifier.height(8.dp)) - - val lastRead = chapterItems.find { it.isCurrentlyReading } ?: chapterItems.maxByOrNull { it.lastRead } - if (lastRead != null && lastRead.progress > 0) { - Button( - onClick = { - val loadUrl = if (lastRead.currentChapterUrl.isNotBlank()) lastRead.currentChapterUrl else lastRead.url - readerViewModel.loadContent(loadUrl, lastRead.id) - libraryViewModel.markAsCurrentlyReading(lastRead.id) - onCloseDrawer() - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) - ) { - Text("Continue: ${lastRead.currentChapter.ifBlank { "Reading" }}") - } - Spacer(modifier = Modifier.height(8.dp)) - } - - val visibleChapters = if (showFullChapters) chapterItems else chapterItems.take(3) - - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - visibleChapters.forEach { chapterItem -> - val isSelected = libraryUiState.selectedIds.contains(chapterItem.id) - val isCurrent = chapterItem.id == lastRead?.id - - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent, - shape = RoundedCornerShape(4.dp) - ) - .combinedClickable( - onClick = { - if (isSelectionMode) { - libraryViewModel.toggleSelection(chapterItem.id) - } else { - val loadUrl = if (chapterItem.currentChapterUrl.isNotBlank()) chapterItem.currentChapterUrl else chapterItem.url - readerViewModel.loadContent(loadUrl, chapterItem.id) - libraryViewModel.markAsCurrentlyReading(chapterItem.id) - onCloseDrawer() - } - }, - onLongClick = { libraryViewModel.toggleSelection(chapterItem.id) } - ) - .padding(vertical = 6.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = chapterItem.currentChapter.ifBlank { "Chapter 1" }, - color = if (isSelected) MaterialTheme.colorScheme.primary else if (isCurrent) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium - ) - } - val chapterUrl = if (chapterItem.currentChapterUrl.isNotBlank()) chapterItem.currentChapterUrl else chapterItem.url - val cachedSummary = chapterItem.chapterSummaries.get(chapterUrl) - val streamingSummary = if (summaryUiState.activeChapterUrl == chapterUrl) summaryUiState.currentSummary else cachedSummary - - ChapterSummaryDropdown( - chapterTitle = chapterItem.currentChapter.ifBlank { chapterItem.title }, - chapterUrl = chapterUrl, - summary = streamingSummary, - isGenerating = summaryUiState.isGenerating && summaryUiState.activeChapterUrl == chapterUrl, - onGenerateSummary = { - scope.launch { - val result = contentRepository.loadContent(chapterUrl) - if (result is ContentRepository.ContentResult.Success) { - summaryViewModel.generateSummary( - chapterUrl = chapterUrl, - chapterTitle = chapterItem.currentChapter.ifBlank { chapterItem.title }, - content = result.elements.filterIsInstance().map { it.content } - ) { summary -> - val updatedSummaries = chapterItem.chapterSummaries.toMutableMap() - updatedSummaries[chapterUrl] = summary - libraryViewModel.updateItem(chapterItem.copy(chapterSummaries = updatedSummaries)) - } - } - } - }, - onCancel = { summaryViewModel.cancelGeneration() } - ) - } - } - - if (chapterItems.size > 3) { - TextButton( - onClick = { showFullChaptersState[groupTitle] = !showFullChapters }, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = if (showFullChapters) "Show Less" else "Show All (${chapterItems.size})", - color = Color(0xFF4CAF50) - ) - } - } - } - } - } - } - } - } +@Composable +private fun SourceHeader( + name: String, + isExpanded: Boolean, + onClick: () -> Unit +): Unit { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f) + ) + Icon( + imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun NovelGroupCard( + title: String, + items: List, + uiState: LibraryViewModel.LibraryUiState, + readerUiState: ReaderViewModel.ReaderUiState, + summaryUiState: SummaryViewModel.SummaryUiState, + isExpanded: Boolean, + showFullChapters: Boolean, + onToggleExpand: () -> Unit, + onToggleShowFull: () -> Unit, + libraryViewModel: LibraryViewModel, + readerViewModel: ReaderViewModel, + summaryViewModel: SummaryViewModel, + onCloseDrawer: () -> Unit +): Unit { + val isGroupSelected = items.all { it.id in uiState.selectedIds } + + Card( + modifier = Modifier.fillMaxWidth().padding(start = 8.dp).clip(RoundedCornerShape(10.dp)), + colors = CardDefaults.cardColors( + containerColor = if (isGroupSelected) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + NovelGroupHeader( + title = title, + items = items, + isExpanded = isExpanded, + isSelectionMode = uiState.isSelectionMode, + readerUiState = readerUiState, + onToggleExpand = onToggleExpand, + onToggleSelection = { libraryViewModel.toggleGroupSelection(title) }, + onOpenItem = { item -> + val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url + readerViewModel.loadContent(loadUrl, item.id) + libraryViewModel.markAsCurrentlyReading(item.id) + onCloseDrawer() + }, + onOpenNewChapter = { item -> + libraryViewModel.openNewChapter(title, item.baseNovelUrl, item.sourceName) { url, id -> + readerViewModel.loadContent(url, id) + libraryViewModel.markAsCurrentlyReading(id) + onCloseDrawer() + } + } + ) + + if (isExpanded) { + NovelChapterList( + items = items, + uiState = uiState, + summaryUiState = summaryUiState, + showFullChapters = showFullChapters, + onToggleShowFull = onToggleShowFull, + onChapterClick = { chapter -> + if (uiState.isSelectionMode) { + libraryViewModel.toggleSelection(chapter.id) + } else { + val loadUrl = if (chapter.currentChapterUrl.isNotBlank()) chapter.currentChapterUrl else chapter.url + readerViewModel.loadContent(loadUrl, chapter.id) + libraryViewModel.markAsCurrentlyReading(chapter.id) + onCloseDrawer() } + }, + onChapterLongClick = { libraryViewModel.toggleSelection(it.id) }, + summaryViewModel = summaryViewModel, + libraryViewModel = libraryViewModel, + readerViewModel = readerViewModel + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun NovelGroupHeader( + title: String, + items: List, + isExpanded: Boolean, + isSelectionMode: Boolean, + readerUiState: ReaderViewModel.ReaderUiState, + onToggleExpand: () -> Unit, + onToggleSelection: () -> Unit, + onOpenItem: (LibraryItem) -> Unit, + onOpenNewChapter: (LibraryItem) -> Unit +): Unit { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .combinedClickable( + onClick = { + if (isSelectionMode) onToggleSelection() + else onOpenItem(items.find { it.isCurrentlyReading } ?: items.maxByOrNull { it.lastRead } ?: items.first()) + }, + onLongClick = onToggleSelection + ) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val hasUpdates = items.any { it.hasUpdates } + val lastItem = items.lastOrNull() + val isCaughtUp = lastItem?.let { it.isCurrentlyReading || it.progress > 0 } ?: false + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = if (hasUpdates && isCaughtUp) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f, fill = false) + ) + + if (hasUpdates) { + Spacer(modifier = Modifier.width(8.dp)) + Badge(modifier = Modifier.clickable { lastItem?.let(onOpenNewChapter) }) { + Text("NEW", style = MaterialTheme.typography.labelSmall) } } } + if (!isExpanded) { + val current = items.find { it.isCurrentlyReading } ?: items.maxByOrNull { it.lastRead } ?: items.first() + Text( + text = current.currentChapter.ifBlank { "Chapter 1" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (current.isCurrentlyReading) { + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { readerUiState.scrollProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary + ) + } + } + } + IconButton(onClick = onToggleExpand) { + Icon( + imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun NovelChapterList( + items: List, + uiState: LibraryViewModel.LibraryUiState, + summaryUiState: SummaryViewModel.SummaryUiState, + showFullChapters: Boolean, + onToggleShowFull: () -> Unit, + onChapterClick: (LibraryItem) -> Unit, + onChapterLongClick: (LibraryItem) -> Unit, + summaryViewModel: SummaryViewModel, + libraryViewModel: LibraryViewModel, + readerViewModel: ReaderViewModel +): Unit { + val scope = rememberCoroutineScope() + Spacer(modifier = Modifier.height(8.dp)) + + val lastRead = items.find { it.isCurrentlyReading } ?: items.maxByOrNull { it.lastRead } + if (lastRead != null && lastRead.progress > 0) { + Button( + onClick = { onChapterClick(lastRead) }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + ) { + Text("Continue: ${lastRead.currentChapter.ifBlank { "Reading" }}") + } + Spacer(modifier = Modifier.height(8.dp)) + } + + val visibleChapters = if (showFullChapters) items else items.take(3) + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + visibleChapters.forEach { chapterItem -> + val isSelected = uiState.selectedIds.contains(chapterItem.id) + val isCurrent = chapterItem.id == lastRead?.id + val chapterUrl = if (chapterItem.currentChapterUrl.isNotBlank()) chapterItem.currentChapterUrl else chapterItem.url + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .background( + if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent, + shape = RoundedCornerShape(4.dp) + ) + .combinedClickable( + onClick = { onChapterClick(chapterItem) }, + onLongClick = { onChapterLongClick(chapterItem) } + ) + .padding(vertical = 6.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = chapterItem.currentChapter.ifBlank { "Chapter 1" }, + color = if (isSelected) MaterialTheme.colorScheme.primary + else if (isCurrent) MaterialTheme.colorScheme.secondary + else MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium + ) + } + + val cachedSummary = chapterItem.chapterSummaries[chapterUrl] + val streamingSummary = if (summaryUiState.activeChapterUrl == chapterUrl) summaryUiState.currentSummary else cachedSummary + + ChapterSummaryDropdown( + chapterTitle = chapterItem.currentChapter.ifBlank { chapterItem.title }, + chapterUrl = chapterUrl, + summary = streamingSummary, + isGenerating = summaryUiState.isGenerating && summaryUiState.activeChapterUrl == chapterUrl, + onGenerateSummary = { + scope.launch { + val result = readerViewModel.contentRepository.loadContent(chapterUrl) + if (result is ContentRepository.ContentResult.Success) { + summaryViewModel.generateSummary( + chapterUrl = chapterUrl, + chapterTitle = chapterItem.currentChapter.ifBlank { chapterItem.title }, + content = result.elements.filterIsInstance().map { it.content } + ) { summary -> + val updatedSummaries = chapterItem.chapterSummaries.toMutableMap() + updatedSummaries[chapterUrl] = summary + libraryViewModel.updateItem(chapterItem.copy(chapterSummaries = updatedSummaries)) + } + } + } + }, + onCancel = { summaryViewModel.cancelGeneration() } + ) + } + } + + if (items.size > 3) { + TextButton( + onClick = onToggleShowFull, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = if (showFullChapters) "Show Less" else "Show All (${items.size})", + color = Color(0xFF4CAF50) + ) + } } } } @@ -601,7 +716,7 @@ private fun EpubItemCard( IconButton(onClick = { isExpanded = !isExpanded }) { Icon( - imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.Filled.KeyboardArrowRight, + imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (isExpanded) "Collapse" else "Expand", tint = Color.White ) @@ -669,7 +784,7 @@ private fun EpubTocItemView( modifier = Modifier.size(20.dp) ) { Icon( - imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.Filled.KeyboardArrowRight, + imageVector = if (isExpanded) Icons.Filled.ArrowDropDown else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = if (isExpanded) "Collapse" else "Expand", tint = Color.Gray, modifier = Modifier.size(16.dp) diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt b/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt index 8c817b3..bdbbd42 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/screens/ReaderScreen.kt @@ -6,18 +6,16 @@ import android.webkit.WebViewClient import androidx.activity.compose.BackHandler import androidx.compose.animation.* import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.foundation.* import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.PagerState -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.filled.* @@ -25,13 +23,9 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate -import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -40,7 +34,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -50,23 +43,14 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.navigation.NavController import coil3.SingletonImageLoader -import coil3.compose.AsyncImage import coil3.request.ImageRequest -import coil3.request.crossfade -import coil3.network.NetworkHeaders -import coil3.network.httpHeaders import io.aatricks.novelscraper.data.model.* -import io.aatricks.novelscraper.ui.ExploreRoute +import io.aatricks.novelscraper.ui.components.* import io.aatricks.novelscraper.ui.viewmodel.LibraryViewModel import io.aatricks.novelscraper.ui.viewmodel.ReaderViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import kotlin.math.abs -import io.aatricks.novelscraper.ui.components.* - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ReaderScreen( @@ -75,7 +59,7 @@ fun ReaderScreen( navController: NavController, onOpenFilePicker: () -> Unit, modifier: Modifier = Modifier -) { +): Unit { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() val context = LocalContext.current @@ -150,41 +134,12 @@ fun ReaderScreen( } if (showCloudflareWebView) { - AlertDialog( - onDismissRequest = { showCloudflareWebView = false }, - title = { Text("Solve Challenge") }, - text = { - Column { - Text("Please solve the challenge to continue reading.", style = MaterialTheme.typography.bodySmall) - Spacer(modifier = Modifier.height(8.dp)) - Box(modifier = Modifier.fillMaxWidth().height(400.dp)) { - AndroidView(factory = { ctx -> - WebView(ctx).apply { - settings.javaScriptEnabled = true - settings.userAgentString = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView?, url: String?) { - super.onPageFinished(view, url) - } - } - loadUrl(cloudflareUrl) - } - }) - } - } - }, - confirmButton = { - Button(onClick = { - showCloudflareWebView = false - readerViewModel.retryLoad() - }) { - Text("Done") - } - }, - dismissButton = { - TextButton(onClick = { showCloudflareWebView = false }) { - Text("Cancel") - } + CloudflareDialog( + url = cloudflareUrl, + onDismiss = { showCloudflareWebView = false }, + onRetry = { + showCloudflareWebView = false + readerViewModel.retryLoad() } ) } @@ -216,30 +171,16 @@ fun ReaderScreen( .fillMaxSize() .padding(paddingValues) ) { - when { - uiState.isLoading -> LoadingState() - uiState.error != null -> ErrorState(error = uiState.error!!, onRetry = { readerViewModel.retryLoad() }) - uiState.content == null -> EmptyState(onOpenLibrary = { scope.launch { drawerState.open() } }) - else -> ContentArea( - content = uiState.content!!, - readerViewModel = readerViewModel, - libraryViewModel = libraryViewModel, - onLibraryClick = { scope.launch { drawerState.open() } }, - onShowChapterList = { showChapterList = true }, - onShowSettings = { showSettings = true } - ) - } + ReaderContent( + uiState = uiState, + readerViewModel = readerViewModel, + onOpenLibrary = { scope.launch { drawerState.open() } }, + onShowChapterList = { showChapterList = true }, + onShowSettings = { showSettings = true } + ) if (uiState.isNavigating) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.3f)) - .pointerInput(Unit) {}, - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = Color(0xFF4CAF50)) - } + NavigationOverlay() } } } @@ -276,18 +217,100 @@ fun ReaderScreen( } } +@Composable +private fun CloudflareDialog( + url: String, + onDismiss: () -> Unit, + onRetry: () -> Unit +): Unit { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Solve Challenge") }, + text = { + Column { + Text( + "Please solve the challenge to continue reading.", + style = MaterialTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + ) { + AndroidView(factory = { ctx -> + WebView(ctx).apply { + settings.javaScriptEnabled = true + settings.userAgentString = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + webViewClient = object : WebViewClient() {} + loadUrl(url) + } + }) + } + } + }, + confirmButton = { + Button(onClick = onRetry) { + Text("Done") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +private fun ReaderContent( + uiState: ReaderViewModel.ReaderUiState, + readerViewModel: ReaderViewModel, + onOpenLibrary: () -> Unit, + onShowChapterList: () -> Unit, + onShowSettings: () -> Unit +): Unit { + when { + uiState.isLoading -> LoadingState() + uiState.error != null -> ErrorState( + error = uiState.error, + onRetry = { readerViewModel.retryLoad() } + ) + uiState.content == null -> EmptyState(onOpenLibrary = onOpenLibrary) + else -> ContentArea( + content = uiState.content, + readerViewModel = readerViewModel, + onLibraryClick = onOpenLibrary, + onShowChapterList = onShowChapterList, + onShowSettings = onShowSettings + ) + } +} + +@Composable +private fun NavigationOverlay(): Unit { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)) + .pointerInput(Unit) {}, + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = Color(0xFF4CAF50)) + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun ContentArea( content: ChapterContent, readerViewModel: ReaderViewModel, - libraryViewModel: LibraryViewModel, onLibraryClick: () -> Unit, onShowChapterList: () -> Unit, onShowSettings: () -> Unit -) { +): Unit { val uiState by readerViewModel.uiState.collectAsState() - val scope = rememberCoroutineScope() val context = LocalContext.current val fontFamily = when (uiState.fontFamily) { @@ -301,7 +324,6 @@ private fun ContentArea( content.getImageCount() > content.getTextCount() && content.getImageCount() > 2 } - // Keyed states to ensure fresh state and correct initial position upon novel switch val listState = key(content.url) { rememberLazyListState( initialFirstVisibleItemIndex = uiState.scrollIndex, @@ -318,7 +340,6 @@ private fun ContentArea( } } - // Aggressive prefetch images nearby LaunchedEffect(listState.firstVisibleItemIndex, pagerState.currentPage, content.url) { val currentIndex = if (uiState.isPagedMode) pagerState.currentPage else listState.firstVisibleItemIndex val prefetchRange = 10 @@ -327,24 +348,24 @@ private fun ContentArea( val endRange = (currentIndex + prefetchRange).coerceAtMost(content.paragraphs.size - 1) for (i in startRange..endRange) { - val element = content.paragraphs.getOrNull(i) - if (element is ContentElement.Image) { - val request = ImageRequest.Builder(context) - .data(element.url) - .build() - SingletonImageLoader.get(context).enqueue(request) - } else if (element is ContentElement.ImageGroup) { - element.images.forEach { img -> - val request = ImageRequest.Builder(context) - .data(img.url) - .build() - SingletonImageLoader.get(context).enqueue(request) + content.paragraphs.getOrNull(i)?.let { element -> + when (element) { + is ContentElement.Image -> { + val request = ImageRequest.Builder(context).data(element.url).build() + SingletonImageLoader.get(context).enqueue(request) + } + is ContentElement.ImageGroup -> { + element.images.forEach { img -> + val request = ImageRequest.Builder(context).data(img.url).build() + SingletonImageLoader.get(context).enqueue(request) + } + } + else -> {} } } } } - // Handle explicit seeks from the slider LaunchedEffect(uiState.seekTrigger) { if (content.paragraphs.isNotEmpty() && uiState.seekTrigger > 0L) { val targetIndex = uiState.scrollIndex @@ -355,9 +376,9 @@ private fun ContentArea( pagerState.scrollToPage(page) } else { if (targetIndex >= 0) { - try { + runCatching { listState.scrollToItem(targetIndex, targetOffset) - } catch (_: Exception) { + }.onFailure { val totalItems = content.paragraphs.size val percent = uiState.scrollPosition.coerceIn(0f, 100f) / 100f val index = (percent * totalItems).toInt().coerceIn(0, totalItems - 1) @@ -372,7 +393,11 @@ private fun ContentArea( LaunchedEffect(pagerState.currentPage) { val totalItems = content.paragraphs.size val currentItem = pagerState.currentPage - val progress = if (totalItems > 0) ((currentItem.toFloat() / (totalItems - 1).coerceAtLeast(1)) * 100f).coerceIn(0f, 100f) else 0f + val progress = if (totalItems > 0) { + ((currentItem.toFloat() / (totalItems - 1).coerceAtLeast(1)) * 100f).coerceIn(0f, 100f) + } else { + 0f + } readerViewModel.updateScrollPosition( scrollOffset = progress, @@ -415,106 +440,18 @@ private fun ContentArea( var pullAmount by remember { mutableFloatStateOf(0f) } val isThresholdReached = abs(pullAmount) >= threshold - LaunchedEffect(content.url) { - pullAmount = 0f - } - - val nestedScrollConnection = remember(content, uiState.isPagedMode, uiState.isRtl, pagerState.currentPage) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if ((abs(available.y) > 5f || abs(available.x) > 5f) && source == NestedScrollSource.Drag) { - readerViewModel.hideControls() - } - - if (uiState.isPagedMode) { - if (pullAmount > 0 && available.x < 0) { - val consumed = available.x.coerceAtLeast(-pullAmount) - pullAmount += consumed - return Offset(consumed, 0f) - } - if (pullAmount < 0 && available.x > 0) { - val consumed = available.x.coerceAtMost(-pullAmount) - pullAmount += consumed - return Offset(consumed, 0f) - } - } else { - if (pullAmount > 0 && available.y < 0) { - val consumed = available.y.coerceAtLeast(-pullAmount) - pullAmount += consumed - return Offset(0f, consumed) - } - if (pullAmount < 0 && available.y > 0) { - val consumed = available.y.coerceAtMost(-pullAmount) - pullAmount += consumed - return Offset(0f, consumed) - } - } - return Offset.Zero - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - if (source == NestedScrollSource.Drag) { - if (uiState.isPagedMode) { - val isAtStart = pagerState.currentPage == 0 - val isAtEnd = pagerState.currentPage == content.paragraphs.size - 1 - - if (uiState.isRtl) { - if (available.x < 0 && isAtStart && uiState.canNavigatePrevious) { - pullAmount += available.x * 0.5f - return Offset(available.x, 0f) - } else if (available.x > 0 && isAtEnd && uiState.canNavigateNext) { - pullAmount += available.x * 0.5f - return Offset(available.x, 0f) - } - } else { - if (available.x > 0 && isAtStart && uiState.canNavigatePrevious) { - pullAmount += available.x * 0.5f - return Offset(available.x, 0f) - } else if (available.x < 0 && isAtEnd && uiState.canNavigateNext) { - pullAmount += available.x * 0.5f - return Offset(available.x, 0f) - } - } - } else { - val isAtTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - val isAtBottom = !listState.canScrollForward - - if (available.y > 0 && isAtTop && uiState.canNavigatePrevious) { - pullAmount += available.y * 0.5f - return Offset(0f, available.y) - } else if (available.y < 0 && isAtBottom && uiState.canNavigateNext) { - pullAmount += available.y * 0.5f - return Offset(0f, available.y) - } - } - } - return Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val currentPull = pullAmount - if (abs(currentPull) >= threshold) { - val isPrevious = if (uiState.isPagedMode) { - if (uiState.isRtl) currentPull < 0 else currentPull > 0 - } else { - currentPull > 0 - } - - if (isPrevious) { - readerViewModel.navigateToPreviousChapter(fromBottom = true) - } else { - readerViewModel.navigateToNextChapter() - } - } - pullAmount = 0f - return Velocity.Zero - } - } - } + val nestedScrollConnection = rememberReaderNestedScrollConnection( + uiState = uiState, + pagerState = pagerState, + listState = listState, + content = content, + threshold = threshold, + onHideControls = { readerViewModel.hideControls() }, + onUserInteraction = { readerViewModel.onUserInteraction() }, + onPullAmountChange = { pullAmount = it }, + onNavigatePrevious = { readerViewModel.navigateToPreviousChapter(fromBottom = true) }, + onNavigateNext = { readerViewModel.navigateToNextChapter() } + ) val readerThemeState = uiState.readerTheme val bgColor = readerThemeState.backgroundColor @@ -526,130 +463,26 @@ private fun ContentArea( .background(bgColor) ) { if (uiState.isPagedMode) { - HorizontalPager( - state = pagerState, - reverseLayout = uiState.isRtl, - userScrollEnabled = !uiState.showControls, - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures(onTap = { readerViewModel.toggleControls() }) - } - ) { page -> - val element = content.paragraphs.getOrNull(page) - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - if (element != null) { - when (element) { - is ContentElement.Text -> { - Box(modifier = Modifier.verticalScroll(rememberScrollState())) { - Text( - text = element.content, - color = textColor, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = uiState.fontSize.sp, - lineHeight = (uiState.fontSize * uiState.lineHeight).sp, - fontFamily = fontFamily - ), - modifier = Modifier.padding(uiState.margins.dp).fillMaxWidth() - ) - } - } - is ContentElement.Image -> { - ReaderImageView( - imageUrl = element.url, - altText = element.altText, - readerViewModel = readerViewModel, - pageUrl = content.url, - contentScale = ContentScale.Fit, - backgroundColor = bgColor, - width = element.width, - height = element.height - ) - } - is ContentElement.ImageGroup -> { - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - element.images.forEach { img -> - ReaderImageView( - imageUrl = img.url, - altText = img.altText, - readerViewModel = readerViewModel, - pageUrl = content.url, - contentScale = ContentScale.FillWidth, - backgroundColor = bgColor, - width = img.width, - height = img.height - ) - } - } - } - } - } - } - } + PagedReaderView( + content = content, + pagerState = pagerState, + uiState = uiState, + fontFamily = fontFamily, + bgColor = bgColor, + textColor = textColor, + readerViewModel = readerViewModel + ) } else { - LazyColumn( - state = listState, - modifier = Modifier - .fillMaxSize() - .pointerInput(Unit) { - detectTapGestures(onTap = { readerViewModel.toggleControls() }) - }, - verticalArrangement = if (isManhwa) Arrangement.spacedBy(0.dp) else Arrangement.spacedBy((uiState.fontSize * uiState.paragraphSpacing).dp) - ) { - itemsIndexed( - content.paragraphs, - key = { index: Int, _: ContentElement -> "${content.url}_$index" }) { index: Int, element: ContentElement -> - when (element) { - is ContentElement.Text -> { - Text( - text = element.content, - color = textColor, - style = MaterialTheme.typography.bodyLarge.copy( - fontSize = uiState.fontSize.sp, - lineHeight = (uiState.fontSize * uiState.lineHeight).sp, - fontFamily = fontFamily - ), - modifier = Modifier.fillMaxWidth().padding(horizontal = uiState.margins.dp) - ) - } - is ContentElement.Image -> { - ReaderImageView( - imageUrl = element.url, - altText = element.altText, - readerViewModel = readerViewModel, - pageUrl = content.url, - contentScale = ContentScale.FillWidth, - backgroundColor = bgColor, - width = element.width, - height = element.height - ) - } - is ContentElement.ImageGroup -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - element.images.forEach { img -> - ReaderImageView( - imageUrl = img.url, - altText = img.altText, - readerViewModel = readerViewModel, - pageUrl = content.url, - contentScale = ContentScale.FillWidth, - backgroundColor = bgColor, - width = img.width, - height = img.height - ) - } - } - } - } - } - } + ScrollingReaderView( + content = content, + listState = listState, + uiState = uiState, + isManhwa = isManhwa, + fontFamily = fontFamily, + bgColor = bgColor, + textColor = textColor, + readerViewModel = readerViewModel + ) } AnimatedVisibility( @@ -687,70 +520,374 @@ private fun ContentArea( ) } - if (abs(pullAmount) > 0f) { - val isPrevious = if (uiState.isPagedMode) { - if (uiState.isRtl) pullAmount < 0 else pullAmount > 0 - } else { - pullAmount > 0 + PullToNavigateOverlay( + pullAmount = pullAmount, + threshold = threshold, + isThresholdReached = isThresholdReached, + isPagedMode = uiState.isPagedMode, + isRtl = uiState.isRtl + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PagedReaderView( + content: ChapterContent, + pagerState: PagerState, + uiState: ReaderViewModel.ReaderUiState, + fontFamily: FontFamily, + bgColor: Color, + textColor: Color, + readerViewModel: ReaderViewModel +): Unit { + HorizontalPager( + state = pagerState, + reverseLayout = uiState.isRtl, + userScrollEnabled = !uiState.showControls, + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures(onTap = { readerViewModel.toggleControls() }) + } + ) { page -> + val element = content.paragraphs.getOrNull(page) + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + element?.let { el -> + when (el) { + is ContentElement.Text -> { + Box(modifier = Modifier.verticalScroll(rememberScrollState())) { + Text( + text = el.content, + color = textColor, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = uiState.fontSize.sp, + lineHeight = (uiState.fontSize * uiState.lineHeight).sp, + fontFamily = fontFamily + ), + modifier = Modifier + .padding(uiState.margins.dp) + .fillMaxWidth() + ) + } + } + is ContentElement.Image -> { + ReaderImageView( + imageUrl = el.url, + altText = el.altText, + readerViewModel = readerViewModel, + pageUrl = content.url, + contentScale = ContentScale.Fit, + backgroundColor = bgColor, + width = el.width, + height = el.height + ) + } + is ContentElement.ImageGroup -> { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + el.images.forEach { img -> + ReaderImageView( + imageUrl = img.url, + altText = img.altText, + readerViewModel = readerViewModel, + pageUrl = content.url, + contentScale = ContentScale.FillWidth, + backgroundColor = bgColor, + width = img.width, + height = img.height + ) + } + } + } + } + } + } + } +} + +@Composable +private fun ScrollingReaderView( + content: ChapterContent, + listState: LazyListState, + uiState: ReaderViewModel.ReaderUiState, + isManhwa: Boolean, + fontFamily: FontFamily, + bgColor: Color, + textColor: Color, + readerViewModel: ReaderViewModel +): Unit { + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures(onTap = { readerViewModel.toggleControls() }) + }, + verticalArrangement = if (isManhwa) { + Arrangement.spacedBy(0.dp) + } else { + Arrangement.spacedBy((uiState.fontSize * uiState.paragraphSpacing).dp) + } + ) { + itemsIndexed( + content.paragraphs, + key = { index, _ -> "${content.url}_$index" } + ) { _, element -> + when (element) { + is ContentElement.Text -> { + Text( + text = element.content, + color = textColor, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = uiState.fontSize.sp, + lineHeight = (uiState.fontSize * uiState.lineHeight).sp, + fontFamily = fontFamily + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = uiState.margins.dp) + ) + } + is ContentElement.Image -> { + ReaderImageView( + imageUrl = element.url, + altText = element.altText, + readerViewModel = readerViewModel, + pageUrl = content.url, + contentScale = ContentScale.FillWidth, + backgroundColor = bgColor, + width = element.width, + height = element.height + ) + } + is ContentElement.ImageGroup -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + element.images.forEach { img -> + ReaderImageView( + imageUrl = img.url, + altText = img.altText, + readerViewModel = readerViewModel, + pageUrl = content.url, + contentScale = ContentScale.FillWidth, + backgroundColor = bgColor, + width = img.width, + height = img.height + ) + } + } + } + } + } + } +} + +@Composable +private fun PullToNavigateOverlay( + pullAmount: Float, + threshold: Float, + isThresholdReached: Boolean, + isPagedMode: Boolean, + isRtl: Boolean +): Unit { + if (abs(pullAmount) <= 0f) return + + val isPrevious = if (isPagedMode) { + if (isRtl) pullAmount < 0 else pullAmount > 0 + } else { + pullAmount > 0 + } + + val arrowColor by animateColorAsState( + if (isThresholdReached) Color(0xFF4CAF50) else Color.White, + label = "arrowColor" + ) + + val alignment = when { + isPagedMode && isPrevious -> if (isRtl) Alignment.CenterStart else Alignment.CenterEnd + isPagedMode && !isPrevious -> if (isRtl) Alignment.CenterEnd else Alignment.CenterStart + !isPagedMode && isPrevious -> Alignment.TopCenter + else -> Alignment.BottomCenter + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Color.Black.copy( + alpha = (abs(pullAmount) / threshold * 0.4f).coerceAtMost(0.4f) + ) + ), + contentAlignment = alignment + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + val icon = when { + isPagedMode && isPrevious -> if (isRtl) Icons.AutoMirrored.Filled.ArrowBack else Icons.AutoMirrored.Filled.ArrowForward + isPagedMode && !isPrevious -> if (isRtl) Icons.AutoMirrored.Filled.ArrowForward else Icons.AutoMirrored.Filled.ArrowBack + !isPagedMode && isPrevious -> Icons.Default.ArrowDownward + else -> Icons.Default.ArrowUpward } - val arrowColor by animateColorAsState( - if (isThresholdReached) Color(0xFF4CAF50) else Color.White, - label = "arrowColor" + val rotation by animateFloatAsState( + if (isThresholdReached) 180f else 0f, + label = "arrowRotation" + ) + + Icon( + imageVector = icon, + contentDescription = null, + tint = arrowColor, + modifier = Modifier + .size(48.dp) + .rotate(if (isPagedMode) 0f else rotation) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = when { + isPrevious && isThresholdReached -> "Release for Previous Chapter" + isPrevious && !isThresholdReached -> "Pull for Previous Chapter" + !isPrevious && isThresholdReached -> "Release for Next Chapter" + else -> "Pull for Next Chapter" + }, + color = arrowColor, + style = MaterialTheme.typography.titleMedium ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun rememberReaderNestedScrollConnection( + uiState: ReaderViewModel.ReaderUiState, + pagerState: PagerState, + listState: LazyListState, + content: ChapterContent, + threshold: Float, + onHideControls: () -> Unit, + onUserInteraction: () -> Unit, + onPullAmountChange: (Float) -> Unit, + onNavigatePrevious: () -> Unit, + onNavigateNext: () -> Unit +): NestedScrollConnection { + var pullAmount by remember { mutableFloatStateOf(0f) } + + return remember(content, uiState.isPagedMode, uiState.isRtl, pagerState.currentPage) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if ((abs(available.y) > 5f || abs(available.x) > 5f) && source == NestedScrollSource.UserInput) { + onHideControls() + onUserInteraction() + } - val alignment = if (uiState.isPagedMode) { - if (isPrevious) { - if (uiState.isRtl) Alignment.CenterStart else Alignment.CenterEnd + if (uiState.isPagedMode) { + if (pullAmount > 0 && available.x < 0) { + val consumed = available.x.coerceAtLeast(-pullAmount) + pullAmount += consumed + onPullAmountChange(pullAmount) + return Offset(consumed, 0f) + } + if (pullAmount < 0 && available.x > 0) { + val consumed = available.x.coerceAtMost(-pullAmount) + pullAmount += consumed + onPullAmountChange(pullAmount) + return Offset(consumed, 0f) + } } else { - if (uiState.isRtl) Alignment.CenterEnd else Alignment.CenterStart + if (pullAmount > 0 && available.y < 0) { + val consumed = available.y.coerceAtLeast(-pullAmount) + pullAmount += consumed + onPullAmountChange(pullAmount) + return Offset(0f, consumed) + } + if (pullAmount < 0 && available.y > 0) { + val consumed = available.y.coerceAtMost(-pullAmount) + pullAmount += consumed + onPullAmountChange(pullAmount) + return Offset(0f, consumed) + } } - } else { - if (isPrevious) Alignment.TopCenter else Alignment.BottomCenter + return Offset.Zero } - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = (abs(pullAmount) / threshold * 0.4f).coerceAtMost(0.4f))), - contentAlignment = alignment - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(32.dp) - ) { - val icon = if (uiState.isPagedMode) { - if (isPrevious) { - if (uiState.isRtl) Icons.AutoMirrored.Filled.ArrowBack else Icons.AutoMirrored.Filled.ArrowForward - } else { - if (uiState.isRtl) Icons.AutoMirrored.Filled.ArrowForward else Icons.AutoMirrored.Filled.ArrowBack + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + + if (uiState.isPagedMode) { + val isAtStart = pagerState.currentPage == 0 + val isAtEnd = pagerState.currentPage == content.paragraphs.size - 1 + + if (uiState.isRtl) { + if (available.x < 0 && isAtStart && uiState.canNavigatePrevious) { + pullAmount += available.x * 0.5f + onPullAmountChange(pullAmount) + return Offset(available.x, 0f) + } else if (available.x > 0 && isAtEnd && uiState.canNavigateNext) { + pullAmount += available.x * 0.5f + onPullAmountChange(pullAmount) + return Offset(available.x, 0f) } } else { - if (isPrevious) Icons.Default.ArrowDownward else Icons.Default.ArrowUpward + if (available.x > 0 && isAtStart && uiState.canNavigatePrevious) { + pullAmount += available.x * 0.5f + onPullAmountChange(pullAmount) + return Offset(available.x, 0f) + } else if (available.x < 0 && isAtEnd && uiState.canNavigateNext) { + pullAmount += available.x * 0.5f + onPullAmountChange(pullAmount) + return Offset(available.x, 0f) + } } + } else { + val isAtTop = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + val isAtBottom = !listState.canScrollForward + + if (available.y > 0 && isAtTop && uiState.canNavigatePrevious) { + pullAmount += available.y * 0.5f + onPullAmountChange(pullAmount) + return Offset(0f, available.y) + } else if (available.y < 0 && isAtBottom && uiState.canNavigateNext) { + pullAmount += available.y * 0.5f + onPullAmountChange(pullAmount) + return Offset(0f, available.y) + } + } + return Offset.Zero + } - val rotation by animateFloatAsState(if (isThresholdReached) 180f else 0f, label = "arrowRotation") + override suspend fun onPreFling(available: Velocity): Velocity { + if (abs(pullAmount) >= threshold) { + val isPrevious = if (uiState.isPagedMode) { + if (uiState.isRtl) pullAmount < 0 else pullAmount > 0 + } else { + pullAmount > 0 + } - Icon( - imageVector = icon, - contentDescription = null, - tint = arrowColor, - modifier = Modifier - .size(48.dp) - .rotate(if (uiState.isPagedMode) 0f else rotation) - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = if (isPrevious) { - if (isThresholdReached) "Release for Previous Chapter" else "Pull for Previous Chapter" - } else { - if (isThresholdReached) "Release for Next Chapter" else "Pull for Next Chapter" - }, - color = arrowColor, - style = MaterialTheme.typography.titleMedium - ) + if (isPrevious) { + onNavigatePrevious() + } else { + onNavigateNext() + } } + pullAmount = 0f + onPullAmountChange(0f) + return Velocity.Zero } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/io/aatricks/novelscraper/ui/screens/explore/ExploreScreen.kt index 9057bc1..46cebf3 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/screens/explore/ExploreScreen.kt @@ -51,7 +51,7 @@ fun ExploreScreen( exploreViewModel: ExploreViewModel, libraryViewModel: LibraryViewModel, onNavigateBack: () -> Unit -) { +): Unit { val uiState by exploreViewModel.uiState.collectAsState() val scope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } @@ -68,172 +68,32 @@ fun ExploreScreen( Scaffold( topBar = { - TopAppBar( - title = { - if (uiState.isSearching) { - TextField( - value = uiState.searchQuery, - onValueChange = { exploreViewModel.updateSearchQuery(it) }, - placeholder = { Text("Search novels...") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent - ), - shape = RoundedCornerShape(24.dp), - trailingIcon = { - if (uiState.searchQuery.isNotEmpty()) { - IconButton(onClick = { exploreViewModel.toggleSearch() }) { - Icon(Icons.Default.Close, contentDescription = "Clear") - } - } - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { exploreViewModel.performSearch() }) - ) - } else { - Text("Explore") - } - }, - actions = { - IconButton(onClick = { showSourceDialog = true }) { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = "Select Source", - tint = if (uiState.selectedSource != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ) - } - IconButton(onClick = { exploreViewModel.toggleSearch() }) { - Icon( - imageVector = if (uiState.isSearching) Icons.Default.Close else Icons.Default.Search, - contentDescription = if (uiState.isSearching) "Close Search" else "Search" - ) - } - }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.Close, contentDescription = "Back") - } - } + ExploreTopBar( + uiState = uiState, + onNavigateBack = onNavigateBack, + onSourceClick = { showSourceDialog = true }, + onToggleSearch = { exploreViewModel.toggleSearch() }, + onSearchQueryChange = { exploreViewModel.updateSearchQuery(it) }, + onPerformSearch = { exploreViewModel.performSearch() } ) }, snackbarHost = { SnackbarHost(snackbarHostState) } ) { paddingValues -> - Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { - if (uiState.availableTags.isNotEmpty() && !uiState.isSearching) { - LazyRow( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - contentPadding = PaddingValues(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - item { - FilterChip( - selected = uiState.selectedTags.isEmpty(), - onClick = { exploreViewModel.clearTags() }, - label = { Text("All") } - ) - } - items(uiState.availableTags) { tag -> - FilterChip( - selected = uiState.selectedTags.contains(tag), - onClick = { exploreViewModel.toggleTag(tag) }, - label = { Text(tag) } - ) - } - } - } - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 140.dp), - contentPadding = PaddingValues(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (uiState.isLoading && uiState.items.isEmpty()) { - items(10) { SkeletonExploreCard() } - } else if (uiState.items.isEmpty()) { - item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { - Box(modifier = Modifier.fillMaxSize().padding(top = 100.dp), contentAlignment = Alignment.Center) { - Text("No items found.", style = MaterialTheme.typography.bodyLarge) - } - } - } else { - items(uiState.items) { item -> - ExploreItemCard(item = item, onClick = { exploreViewModel.selectItem(item) }) - } - - if (uiState.isLoading) { - items(4) { SkeletonExploreCard() } - } else { - item { - LaunchedEffect(true) { - exploreViewModel.loadMore() - } - } - } - } - } - } - } + ExploreContent( + uiState = uiState, + paddingValues = paddingValues, + onTagToggle = { exploreViewModel.toggleTag(it) }, + onClearTags = { exploreViewModel.clearTags() }, + onItemSelect = { exploreViewModel.selectItem(it) }, + onLoadMore = { exploreViewModel.loadMore() } + ) } if (showSourceDialog) { - AlertDialog( - onDismissRequest = { showSourceDialog = false }, - containerColor = MaterialTheme.colorScheme.surface, - title = { Text("Select Source", style = MaterialTheme.typography.titleLarge) }, - text = { - LazyColumn(modifier = Modifier.fillMaxWidth()) { - item { - ListItem( - headlineContent = { Text("All Sources") }, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { - exploreViewModel.selectSource(null) - showSourceDialog = false - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - headlineColor = if (uiState.selectedSource == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ), - trailingContent = { - RadioButton( - selected = uiState.selectedSource == null, - onClick = null, - colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colorScheme.primary) - ) - } - ) - } - items(uiState.sources) { sourceName -> - ListItem( - headlineContent = { Text(sourceName) }, - modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable { - exploreViewModel.selectSource(sourceName) - showSourceDialog = false - }, - colors = ListItemDefaults.colors( - containerColor = Color.Transparent, - headlineColor = if (uiState.selectedSource == sourceName) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - ), - trailingContent = { - RadioButton( - selected = uiState.selectedSource == sourceName, - onClick = null, - colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colorScheme.primary) - ) - } - ) - } - } - }, - confirmButton = { - TextButton(onClick = { showSourceDialog = false }) { Text("Close") } - } + SourceSelectionDialog( + uiState = uiState, + onDismiss = { showSourceDialog = false }, + onSourceSelect = { exploreViewModel.selectSource(it) } ) } @@ -249,9 +109,7 @@ fun ExploreScreen( onAddToLibrary = { val itemToAdd = uiState.selectedItemDetails ?: uiState.selectedItem!! libraryViewModel.addExploreItem(itemToAdd, exploreViewModel.exploreRepository) - scope.launch { - snackbarHostState.showSnackbar("Adding to library...") - } + scope.launch { snackbarHostState.showSnackbar("Adding to library...") } exploreViewModel.dismissItem() } ) @@ -259,8 +117,256 @@ fun ExploreScreen( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ExploreTopBar( + uiState: ExploreViewModel.ExploreUiState, + onNavigateBack: () -> Unit, + onSourceClick: () -> Unit, + onToggleSearch: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onPerformSearch: () -> Unit +): Unit { + TopAppBar( + title = { + if (uiState.isSearching) { + SearchTextField( + query = uiState.searchQuery, + onQueryChange = onSearchQueryChange, + onPerformSearch = onPerformSearch, + onClear = onToggleSearch + ) + } else { + Text("Explore") + } + }, + actions = { + IconButton(onClick = onSourceClick) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = "Select Source", + tint = if (uiState.selectedSource != null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ) + } + IconButton(onClick = onToggleSearch) { + Icon( + imageVector = if (uiState.isSearching) Icons.Default.Close else Icons.Default.Search, + contentDescription = if (uiState.isSearching) "Close Search" else "Search" + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.Close, contentDescription = "Back") + } + } + ) +} + @Composable -fun ExploreItemCard(item: ExploreItem, onClick: () -> Unit) { +private fun SearchTextField( + query: String, + onQueryChange: (String) -> Unit, + onPerformSearch: () -> Unit, + onClear: () -> Unit +): Unit { + TextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search novels...") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + shape = RoundedCornerShape(24.dp), + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = onClear) { + Icon(Icons.Default.Close, contentDescription = "Clear") + } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onPerformSearch() }) + ) +} + +@Composable +private fun ExploreContent( + uiState: ExploreViewModel.ExploreUiState, + paddingValues: PaddingValues, + onTagToggle: (String) -> Unit, + onClearTags: () -> Unit, + onItemSelect: (ExploreItem) -> Unit, + onLoadMore: () -> Unit +): Unit { + Column(modifier = Modifier.padding(paddingValues).fillMaxSize()) { + if (uiState.availableTags.isNotEmpty() && !uiState.isSearching) { + TagRow( + availableTags = uiState.availableTags, + selectedTags = uiState.selectedTags, + onTagToggle = onTagToggle, + onClearTags = onClearTags + ) + } + + ExploreGrid( + uiState = uiState, + onItemSelect = onItemSelect, + onLoadMore = onLoadMore + ) + } +} + +@Composable +private fun TagRow( + availableTags: List, + selectedTags: Set, + onTagToggle: (String) -> Unit, + onClearTags: () -> Unit +): Unit { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + FilterChip( + selected = selectedTags.isEmpty(), + onClick = onClearTags, + label = { Text("All") } + ) + } + items(availableTags) { tag -> + FilterChip( + selected = selectedTags.contains(tag), + onClick = { onTagToggle(tag) }, + label = { Text(tag) } + ) + } + } +} + +@Composable +private fun ExploreGrid( + uiState: ExploreViewModel.ExploreUiState, + onItemSelect: (ExploreItem) -> Unit, + onLoadMore: () -> Unit +): Unit { + Box(modifier = Modifier.fillMaxWidth()) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 140.dp), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + when { + uiState.isLoading && uiState.items.isEmpty() -> { + items(10) { SkeletonExploreCard() } + } + uiState.items.isEmpty() -> { + item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + EmptyExploreState() + } + } + else -> { + items(uiState.items) { item -> + ExploreItemCard(item = item, onClick = { onItemSelect(item) }) + } + + if (uiState.isLoading) { + items(4) { SkeletonExploreCard() } + } else { + item { + LaunchedEffect(Unit) { onLoadMore() } + } + } + } + } + } + } +} + +@Composable +private fun EmptyExploreState(): Unit { + Box( + modifier = Modifier.fillMaxSize().padding(top = 100.dp), + contentAlignment = Alignment.Center + ) { + Text("No items found.", style = MaterialTheme.typography.bodyLarge) + } +} + +@Composable +private fun SourceSelectionDialog( + uiState: ExploreViewModel.ExploreUiState, + onDismiss: () -> Unit, + onSourceSelect: (String?) -> Unit +): Unit { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.colorScheme.surface, + title = { Text("Select Source", style = MaterialTheme.typography.titleLarge) }, + text = { + LazyColumn(modifier = Modifier.fillMaxWidth()) { + item { + SourceListItem( + name = "All Sources", + isSelected = uiState.selectedSource == null, + onClick = { + onSourceSelect(null) + onDismiss() + } + ) + } + items(uiState.sources) { sourceName -> + SourceListItem( + name = sourceName, + isSelected = uiState.selectedSource == sourceName, + onClick = { + onSourceSelect(sourceName) + onDismiss() + } + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} + +@Composable +private fun SourceListItem( + name: String, + isSelected: Boolean, + onClick: () -> Unit +): Unit { + ListItem( + headlineContent = { Text(name) }, + modifier = Modifier.clip(RoundedCornerShape(8.dp)).clickable(onClick = onClick), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + ), + trailingContent = { + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors(selectedColor = MaterialTheme.colorScheme.primary) + ) + } + ) +} + +@Composable +fun ExploreItemCard(item: ExploreItem, onClick: () -> Unit): Unit { val context = LocalContext.current val imageRequest = remember(item.coverUrl, item.url) { val uri = try { java.net.URI(item.url) } catch (e: Exception) { null } @@ -329,9 +435,9 @@ fun ExploreItemCard(item: ExploreItem, onClick: () -> Unit) { color = MaterialTheme.colorScheme.primaryContainer, fontWeight = FontWeight.Medium ) - if (item.author != null) { + item.author?.let { author -> Text( - text = item.author!!, + text = author, style = MaterialTheme.typography.labelSmall, color = Color.LightGray, maxLines = 1, @@ -347,7 +453,7 @@ fun ExploreItemCard(item: ExploreItem, onClick: () -> Unit) { } @Composable -fun SkeletonExploreCard() { +fun SkeletonExploreCard(): Unit { val infiniteTransition = rememberInfiniteTransition(label = "skeleton") val alpha by infiniteTransition.animateFloat( initialValue = 0.3f, @@ -377,7 +483,7 @@ fun ExploreItemDetailSheet( item: ExploreItem, isLoading: Boolean = false, onAddToLibrary: () -> Unit -) { +): Unit { val context = LocalContext.current val imageRequest = remember(item.coverUrl, item.url) { val uri = try { java.net.URI(item.url) } catch (e: Exception) { null } @@ -416,9 +522,9 @@ fun ExploreItemDetailSheet( fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.height(4.dp)) - if (item.author != null) { + item.author?.let { author -> Text( - text = "Author: ${item.author}", + text = "Author: $author", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -462,7 +568,7 @@ fun ExploreItemDetailSheet( } } else { Text( - text = if (item.summary.isNullOrBlank()) "No summary available." else item.summary!!, + text = item.summary.takeIf { !it.isNullOrBlank() } ?: "No summary available.", style = MaterialTheme.typography.bodyMedium, lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.5, color = MaterialTheme.colorScheme.onSurface @@ -470,4 +576,4 @@ fun ExploreItemDetailSheet( } Spacer(modifier = Modifier.height(32.dp)) } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/BaseViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/BaseViewModel.kt index bd3f587..d2508e8 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/BaseViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/BaseViewModel.kt @@ -13,7 +13,7 @@ abstract class BaseViewModel(initialState: S) : ViewModel() { protected val _uiState = MutableStateFlow(initialState) val uiState: StateFlow = _uiState.asStateFlow() - protected fun updateState(block: (S) -> S) { + protected fun updateState(block: (S) -> S): Unit { _uiState.update(block) } @@ -23,17 +23,17 @@ abstract class BaseViewModel(initialState: S) : ViewModel() { loadingState: (S, Boolean) -> S, errorState: (S, String?) -> S, block: suspend CoroutineScope.() -> Unit - ) { + ): Unit { viewModelScope.launch { - try { - if (handleLoading) updateState { loadingState(it, true) } - if (handleError) updateState { errorState(it, null) } - block() - if (handleLoading) updateState { loadingState(it, false) } - } catch (e: Exception) { - if (handleLoading) updateState { loadingState(it, false) } - if (handleError) updateState { errorState(it, e.message ?: "An unknown error occurred") } - } + if (handleLoading) updateState { loadingState(it, true) } + if (handleError) updateState { errorState(it, null) } + + runCatching { block() } + .onFailure { e -> + if (handleError) updateState { errorState(it, e.message ?: "An unknown error occurred") } + } + + if (handleLoading) updateState { loadingState(it, false) } } } } diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ExploreViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ExploreViewModel.kt index ca0c26a..3302dcd 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ExploreViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ExploreViewModel.kt @@ -36,20 +36,24 @@ class ExploreViewModel @Inject constructor( loadInitialData() } - private fun loadInitialData() { + private fun loadInitialData(): Unit { viewModelScope.launch { - val tags = exploreRepository.getTags(_uiState.value.selectedSource) - updateState { it.copy(isLoading = true, availableTags = tags) } - val novels = exploreRepository.getPopularNovels(1, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) - updateState { it.copy(items = novels, isLoading = false, page = 1) } + runCatching { + val tags = exploreRepository.getTags(_uiState.value.selectedSource) + updateState { it.copy(isLoading = true, availableTags = tags) } + val novels = exploreRepository.getPopularNovels(1, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) + updateState { it.copy(items = novels, isLoading = false, page = 1) } + }.onFailure { + updateState { it.copy(isLoading = false) } + } } } - fun updateSearchQuery(query: String) { + fun updateSearchQuery(query: String): Unit { updateState { it.copy(searchQuery = query) } } - fun toggleSearch() { + fun toggleSearch(): Unit { val currentlySearching = _uiState.value.isSearching if (currentlySearching) { updateState { it.copy(isSearching = false, searchQuery = "") } @@ -59,77 +63,106 @@ class ExploreViewModel @Inject constructor( } } - fun selectSource(sourceName: String?) { + fun selectSource(sourceName: String?): Unit { viewModelScope.launch { - updateState { it.copy(selectedSource = sourceName, isLoading = true, page = 1, selectedTags = emptySet()) } - val tags = exploreRepository.getTags(sourceName) - val novels = exploreRepository.getPopularNovels(1, sourceName, emptyList()) - updateState { it.copy(items = novels, isLoading = false, availableTags = tags) } + runCatching { + updateState { it.copy(selectedSource = sourceName, isLoading = true, page = 1, selectedTags = emptySet()) } + val tags = exploreRepository.getTags(sourceName) + val novels = exploreRepository.getPopularNovels(1, sourceName, emptyList()) + updateState { it.copy(items = novels, isLoading = false, availableTags = tags) } + }.onFailure { + updateState { it.copy(isLoading = false) } + } } } - fun toggleTag(tag: String) { + fun toggleTag(tag: String): Unit { viewModelScope.launch { - val newTags = if (_uiState.value.selectedTags.contains(tag)) { - _uiState.value.selectedTags - tag - } else { - _uiState.value.selectedTags + tag + runCatching { + val newTags = if (_uiState.value.selectedTags.contains(tag)) { + _uiState.value.selectedTags - tag + } else { + _uiState.value.selectedTags + tag + } + updateState { it.copy(selectedTags = newTags, isLoading = true, page = 1) } + val novels = exploreRepository.getPopularNovels(1, _uiState.value.selectedSource, newTags.toList()) + updateState { it.copy(items = novels, isLoading = false) } + }.onFailure { + updateState { it.copy(isLoading = false) } } - updateState { it.copy(selectedTags = newTags, isLoading = true, page = 1) } - val novels = exploreRepository.getPopularNovels(1, _uiState.value.selectedSource, newTags.toList()) - updateState { it.copy(items = novels, isLoading = false) } } } - fun clearTags() { + fun clearTags(): Unit { viewModelScope.launch { - updateState { it.copy(selectedTags = emptySet(), isLoading = true, page = 1) } - val novels = exploreRepository.getPopularNovels(1, _uiState.value.selectedSource, emptyList()) - updateState { it.copy(items = novels, isLoading = false) } + runCatching { + updateState { it.copy(selectedTags = emptySet(), isLoading = true, page = 1) } + val novels = exploreRepository.getPopularNovels(1, _uiState.value.selectedSource, emptyList()) + updateState { it.copy(items = novels, isLoading = false) } + }.onFailure { + updateState { it.copy(isLoading = false) } + } } } - fun performSearch() { + fun performSearch(): Unit { if (_uiState.value.searchQuery.isBlank()) return viewModelScope.launch { - updateState { it.copy(isLoading = true, page = 1) } - val novels = exploreRepository.searchNovels(_uiState.value.searchQuery, 1, _uiState.value.selectedSource) - updateState { it.copy(items = novels, isLoading = false) } + runCatching { + updateState { it.copy(isLoading = true, page = 1) } + val novels = exploreRepository.searchNovels(_uiState.value.searchQuery, 1, _uiState.value.selectedSource) + updateState { it.copy(items = novels, isLoading = false) } + }.onFailure { + updateState { it.copy(isLoading = false) } + } } } - fun loadMore() { + fun loadMore(): Unit { if (_uiState.value.isLoading) return viewModelScope.launch { - updateState { it.copy(isLoading = true) } - val nextPage = _uiState.value.page + 1 - val newItems = if (_uiState.value.isSearching && _uiState.value.searchQuery.isNotBlank()) { - exploreRepository.searchNovels(_uiState.value.searchQuery, nextPage, _uiState.value.selectedSource) - } else { - exploreRepository.getPopularNovels(nextPage, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) - } - - val distinctNewItems = newItems.filter { newItem -> - _uiState.value.items.none { it.url == newItem.url } + runCatching { + updateState { it.copy(isLoading = true) } + val nextPage = _uiState.value.page + 1 + val newItems = fetchItems(nextPage) + + val distinctNewItems = newItems.filter { newItem -> + _uiState.value.items.none { it.url == newItem.url } + } + + updateState { it.copy( + items = it.items + distinctNewItems, + isLoading = false, + page = if (distinctNewItems.isNotEmpty()) nextPage else it.page + ) } + }.onFailure { + updateState { it.copy(isLoading = false) } } - - updateState { it.copy( - items = it.items + distinctNewItems, - isLoading = false, - page = if (distinctNewItems.isNotEmpty()) nextPage else it.page - ) } } } - fun selectItem(item: ExploreItem) { + private suspend fun fetchItems(page: Int): List { + return if (_uiState.value.isSearching && _uiState.value.searchQuery.isNotBlank()) { + exploreRepository.searchNovels(_uiState.value.searchQuery, page, _uiState.value.selectedSource) + } else { + exploreRepository.getPopularNovels(page, _uiState.value.selectedSource, _uiState.value.selectedTags.toList()) + } + } + + fun selectItem(item: ExploreItem): Unit { updateState { it.copy(selectedItem = item, selectedItemDetails = null, isFetchingDetails = true) } viewModelScope.launch { - val details = exploreRepository.getNovelDetails(item.url, item.source) - updateState { it.copy(selectedItemDetails = details ?: item, isFetchingDetails = false) } + runCatching { + val details = exploreRepository.getNovelDetails(item.url, item.source) + updateState { it.copy(selectedItemDetails = details ?: item, isFetchingDetails = false) } + }.onFailure { + updateState { it.copy(isFetchingDetails = false, selectedItemDetails = item) } + } } } - fun dismissItem() { + fun dismissItem(): Unit { updateState { it.copy(selectedItem = null, selectedItemDetails = null) } } + } diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt index 03f3e57..07857ac 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/LibraryViewModel.kt @@ -58,7 +58,7 @@ class LibraryViewModel @Inject constructor( PROGRESS } - private fun observeLibraryChanges() { + private fun observeLibraryChanges(): Unit { viewModelScope.launch { val repoFlow = combine( repository.libraryItems, @@ -74,30 +74,9 @@ class LibraryViewModel @Inject constructor( _contentTypeFilter, _sortMode ) { repoData, query, filter, sort -> - val items = repoData.first - val selectedIds = repoData.second - val collapsedSources = repoData.third + val (items, selectedIds, collapsedSources) = repoData - var filteredItems = items - - if (filter != null) { - filteredItems = filteredItems.filter { it.contentType == filter } - } - - if (query.isNotBlank()) { - val lowercaseQuery = query.trim().lowercase() - filteredItems = filteredItems.filter { - it.title.lowercase().contains(lowercaseQuery) || - it.baseTitle.lowercase().contains(lowercaseQuery) - } - } - - filteredItems = when (sort) { - SortMode.LAST_READ -> filteredItems.sortedByDescending { it.lastRead } - SortMode.DATE_ADDED -> filteredItems.sortedByDescending { it.dateAdded } - SortMode.TITLE -> filteredItems.sortedBy { it.title.lowercase() } - SortMode.PROGRESS -> filteredItems.sortedByDescending { it.progress } - } + val filteredItems = filterAndSortItems(items, query, filter, sort) LibraryUiState( items = items, @@ -117,19 +96,45 @@ class LibraryViewModel @Inject constructor( } } + private fun filterAndSortItems( + items: List, + query: String, + filter: ContentType?, + sort: SortMode + ): List { + var filtered = items + + if (filter != null) { + filtered = filtered.filter { it.contentType == filter } + } + + if (query.isNotBlank()) { + val lowercaseQuery = query.trim().lowercase() + filtered = filtered.filter { + it.title.lowercase().contains(lowercaseQuery) || + it.baseTitle.lowercase().contains(lowercaseQuery) + } + } + + return when (sort) { + SortMode.LAST_READ -> filtered.sortedByDescending { it.lastRead } + SortMode.DATE_ADDED -> filtered.sortedByDescending { it.dateAdded } + SortMode.TITLE -> filtered.sortedBy { it.title.lowercase() } + SortMode.PROGRESS -> filtered.sortedByDescending { it.progress } + } + } + fun addItem( title: String, url: String, contentType: ContentType, currentChapter: String = "Chapter 1" - ) { + ): Unit { viewModelScope.launch { - try { + runCatching { updateState { it.copy(isLoading = true, error = null) } - val existingItem = repository.getItemByUrl(url) - if (existingItem != null) { - updateState { it.copy(isLoading = false, error = "This item already exists in your library") } - return@launch + if (repository.getItemByUrl(url) != null) { + throw Exception("This item already exists in your library") } val baseTitle = TextUtils.extractBaseTitle(title, contentType) repository.addItem( @@ -140,47 +145,28 @@ class LibraryViewModel @Inject constructor( baseTitle = baseTitle ) updateState { it.copy(isLoading = false) } - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(isLoading = false, error = "Failed to add item: ${e.message}") } } } } - fun addExploreItem(item: io.aatricks.novelscraper.data.model.ExploreItem, exploreRepository: ExploreRepository) { + fun addExploreItem( + item: io.aatricks.novelscraper.data.model.ExploreItem, + exploreRepository: ExploreRepository + ): Unit { viewModelScope.launch { - try { + runCatching { updateState { it.copy(isLoading = true) } - val readingUrl = item.readingUrl ?: run { - val details = exploreRepository.getNovelDetails(item.url, item.source) - details?.readingUrl ?: item.url - } - val existing = repository.getItemByUrl(readingUrl) - if (existing != null) { - updateState { it.copy(isLoading = false, error = "Item already in library") } - return@launch - } - val contentType = when { - readingUrl.endsWith(".epub", ignoreCase = true) -> ContentType.EPUB - readingUrl.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF - else -> ContentType.WEB + val readingUrl = item.readingUrl ?: exploreRepository.getNovelDetails(item.url, item.source)?.readingUrl ?: item.url + + if (repository.getItemByUrl(readingUrl) != null) { + throw Exception("Item already in library") } + + val contentType = determineContentType(readingUrl) if (contentType == ContentType.WEB) { - val chapterTitle = contentRepository.fetchTitle(readingUrl) ?: "Chapter 1" - val fullTitle = if (chapterTitle.contains(item.title, ignoreCase = true)) { - chapterTitle - } else { - "${item.title} - $chapterTitle" - } - repository.addItem( - title = fullTitle, - url = readingUrl, - contentType = ContentType.WEB, - currentChapter = TextUtils.extractChapterLabel(chapterTitle) ?: "Chapter 1", - baseTitle = item.title, - baseNovelUrl = item.url, - sourceName = item.source, - totalChapters = item.chapterCount - ) + addWebExploreItem(item, readingUrl) } else { repository.addItem( title = item.title, @@ -194,66 +180,80 @@ class LibraryViewModel @Inject constructor( ) } updateState { it.copy(isLoading = false) } - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(isLoading = false, error = "Failed to add: ${e.message}") } } } } + private fun determineContentType(url: String): ContentType { + return when { + url.endsWith(".epub", ignoreCase = true) -> ContentType.EPUB + url.endsWith(".pdf", ignoreCase = true) -> ContentType.PDF + else -> ContentType.WEB + } + } + + private suspend fun addWebExploreItem( + item: io.aatricks.novelscraper.data.model.ExploreItem, + readingUrl: String + ): Unit { + val chapterTitle = contentRepository.fetchTitle(readingUrl) ?: "Chapter 1" + val fullTitle = if (chapterTitle.contains(item.title, ignoreCase = true)) { + chapterTitle + } else { + "${item.title} - $chapterTitle" + } + repository.addItem( + title = fullTitle, + url = readingUrl, + contentType = ContentType.WEB, + currentChapter = TextUtils.extractChapterLabel(chapterTitle) ?: "Chapter 1", + baseTitle = item.title, + baseNovelUrl = item.url, + sourceName = item.source, + totalChapters = item.chapterCount + ) + } + fun addChapters( chapters: List, baseTitle: String, baseNovelUrl: String, sourceName: String - ) { + ): Unit { viewModelScope.launch { chapters.forEach { chapter -> - try { + runCatching { if (repository.getItemByUrl(chapter.url) == null) { repository.addItem( title = chapter.title, url = chapter.url, contentType = ContentType.WEB, - currentChapter = TextUtils.extractChapterLabel(chapter.title) ?: TextUtils.extractChapterLabelFromUrl(chapter.url) ?: chapter.title, + currentChapter = TextUtils.extractChapterLabel(chapter.title) + ?: TextUtils.extractChapterLabelFromUrl(chapter.url) + ?: chapter.title, baseTitle = baseTitle, baseNovelUrl = baseNovelUrl, sourceName = sourceName ) contentRepository.prefetch(chapter.url) } - } catch (_: Exception) {} + } } } } - fun fetchAndAdd(url: String) { + fun fetchAndAdd(url: String): Unit { viewModelScope.launch { - try { + runCatching { updateState { it.copy(isLoading = true, error = null) } - val existing = repository.getItemByUrl(url) - if (existing != null) { - updateState { it.copy(isLoading = false, error = "This item already exists in your library") } - return@launch - } - val contentType = when { - url.endsWith(".epub", ignoreCase = true) || url.contains("epub") -> ContentType.EPUB - url.endsWith(".pdf", ignoreCase = true) || url.contains("pdf") -> ContentType.PDF - url.endsWith(".html", ignoreCase = true) || url.endsWith(".htm", ignoreCase = true) -> ContentType.HTML - url.startsWith("http://") || url.startsWith("https://") -> ContentType.WEB - else -> { - when { - url.contains("epub", ignoreCase = true) -> ContentType.EPUB - url.contains("pdf", ignoreCase = true) -> ContentType.PDF - url.contains("html", ignoreCase = true) -> ContentType.HTML - else -> ContentType.WEB - } - } - } - val fetchedTitle = try { - contentRepository.fetchTitle(url) ?: url - } catch (e: Exception) { - url + if (repository.getItemByUrl(url) != null) { + throw Exception("This item already exists in your library") } + val contentType = inferContentType(url) + val fetchedTitle = runCatching { contentRepository.fetchTitle(url) }.getOrNull() ?: url + if (contentType == ContentType.EPUB) { repository.addItem( title = fetchedTitle.trim().ifBlank { url }, @@ -265,128 +265,125 @@ class LibraryViewModel @Inject constructor( sourceName = "EPUB" ) } else { - val chapterLabel = TextUtils.extractChapterLabel(fetchedTitle) ?: TextUtils.extractChapterLabelFromUrl(fetchedTitle) ?: "Chapter 1" val fullTitle = fetchedTitle.trim().ifBlank { url } val baseTitle = TextUtils.extractBaseTitle(fullTitle, contentType) repository.addItem( title = fullTitle, url = url.trim(), contentType = contentType, - currentChapter = chapterLabel, + currentChapter = TextUtils.extractChapterLabel(fullTitle) ?: "Chapter 1", baseTitle = baseTitle, baseNovelUrl = url, - sourceName = "Web" + sourceName = if (url.startsWith("http")) "Web" else "File" ) } updateState { it.copy(isLoading = false) } - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(isLoading = false, error = "Failed to add item: ${e.message}") } } } } + private fun inferContentType(url: String): ContentType { + return when { + url.endsWith(".epub", ignoreCase = true) || url.contains("epub") -> ContentType.EPUB + url.endsWith(".pdf", ignoreCase = true) || url.contains("pdf") -> ContentType.PDF + url.endsWith(".html", ignoreCase = true) || url.endsWith(".htm", ignoreCase = true) -> ContentType.HTML + else -> ContentType.WEB + } + } + fun openNewChapter( baseTitle: String, baseNovelUrl: String, sourceName: String, onChapterLoaded: (String, String) -> Unit - ) { + ): Unit { viewModelScope.launch { - try { + runCatching { updateState { it.copy(isLoading = true) } val details = exploreRepository.getNovelDetails(baseNovelUrl, sourceName) - if (details != null && details.chapters.isNotEmpty()) { - // Chapters are unified to Ascending in sources (1 to N) - // We want the latest one - val latestChapter = details.chapters.last() - - var item = repository.getItemByUrl(latestChapter.url) - if (item == null) { - item = repository.addItem( - title = latestChapter.title, - url = latestChapter.url, - contentType = ContentType.WEB, - currentChapter = TextUtils.extractChapterLabel(latestChapter.title) - ?: TextUtils.extractChapterLabelFromUrl(latestChapter.url) - ?: latestChapter.title, - baseTitle = baseTitle, - baseNovelUrl = baseNovelUrl, - sourceName = sourceName, - totalChapters = details.chapters.size - ) - contentRepository.prefetch(latestChapter.url) - } else { - // If it's already in library, just update its metadata if needed - if (item.totalChapters < details.chapters.size) { - repository.updateItem(item.copy(totalChapters = details.chapters.size)) - } - } - - // Clear update indicator for this novel - repository.clearUpdateIndicator(item.id) - - onChapterLoaded(item.url, item.id) - } else { - updateState { it.copy(error = "No chapters found for this novel") } + if (details == null || details.chapters.isEmpty()) { + throw Exception("No chapters found for this novel") } + + val latestChapter = details.chapters.last() + var item = repository.getItemByUrl(latestChapter.url) + + if (item == null) { + item = repository.addItem( + title = latestChapter.title, + url = latestChapter.url, + contentType = ContentType.WEB, + currentChapter = TextUtils.extractChapterLabel(latestChapter.title) + ?: TextUtils.extractChapterLabelFromUrl(latestChapter.url) + ?: latestChapter.title, + baseTitle = baseTitle, + baseNovelUrl = baseNovelUrl, + sourceName = sourceName, + totalChapters = details.chapters.size + ) + contentRepository.prefetch(latestChapter.url) + } else if (item.totalChapters < details.chapters.size) { + repository.updateItem(item.copy(totalChapters = details.chapters.size)) + } + + repository.clearUpdateIndicator(item!!.id) + onChapterLoaded(item.url, item.id) updateState { it.copy(isLoading = false) } - } catch (e: Exception) { + }.onFailure { e -> Log.e(TAG, "Failed to open new chapter", e) updateState { it.copy(isLoading = false, error = "Failed to load new chapter: ${e.message}") } } } } - fun prefetchLibrary(selectedOnly: Boolean = false) { + fun prefetchLibrary(selectedOnly: Boolean = false): Unit { viewModelScope.launch { - try { + runCatching { updateState { it.copy(isLoading = true) } val items = if (selectedOnly) repository.getSelectedItems() else repository.libraryItems.value items.forEach { item -> - try { - contentRepository.prefetch(item.url) - } catch (_: Exception) {} + runCatching { contentRepository.prefetch(item.url) } } updateState { it.copy(isLoading = false) } - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(isLoading = false, error = "Prefetch failed: ${e.message}") } } } } - fun removeItem(itemId: String) { + fun removeItem(itemId: String): Unit { viewModelScope.launch { - try { - val item = repository.getItemById(itemId) - if (item != null) { + runCatching { + repository.getItemById(itemId)?.let { item -> contentRepository.clearCache(item.url) } repository.removeItem(itemId) - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(error = "Failed to remove item: ${e.message}") } } } } - fun removeItems(itemIds: Set) { + fun removeItems(itemIds: Set): Unit { viewModelScope.launch { - try { + runCatching { itemIds.forEach { id -> - val item = repository.getItemById(id) - if (item != null) { + repository.getItemById(id)?.let { item -> contentRepository.clearCache(item.url) } } repository.removeItems(itemIds) - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(error = "Failed to remove items: ${e.message}") } } } } - fun removeGroup(baseTitle: String) { + fun removeGroup(baseTitle: String): Unit { viewModelScope.launch { - try { + runCatching { val groupItems = uiState.value.groupedItems[baseTitle] ?: emptyList() if (groupItems.isNotEmpty()) { groupItems.forEach { item -> @@ -395,58 +392,51 @@ class LibraryViewModel @Inject constructor( val ids = groupItems.map { it.id }.toSet() repository.removeItems(ids) } - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(error = "Failed to remove group: ${e.message}") } } } } - fun updateItem(item: LibraryItem) { + fun updateItem(item: LibraryItem): Unit { viewModelScope.launch { - try { - repository.updateItem(item) - } catch (e: Exception) { - updateState { it.copy(error = "Failed to update item: ${e.message}") } - } + runCatching { repository.updateItem(item) } + .onFailure { e -> updateState { it.copy(error = "Failed to update item: ${e.message}") } } } } - fun updateProgress(itemId: String, currentChapter: String, progress: Int) { + fun updateProgress(itemId: String, currentChapter: String, progress: Int): Unit { viewModelScope.launch { - try { - repository.updateProgress(itemId, currentChapter, progress) - } catch (_: Exception) {} + runCatching { repository.updateProgress(itemId, currentChapter, progress) } } } - fun markAsCurrentlyReading(itemId: String) { + fun markAsCurrentlyReading(itemId: String): Unit { viewModelScope.launch { - try { - repository.markAsCurrentlyReading(itemId) - } catch (e: Exception) { - updateState { it.copy(error = "Failed to mark item: ${e.message}") } - } + runCatching { repository.markAsCurrentlyReading(itemId) } + .onFailure { e -> updateState { it.copy(error = "Failed to mark item: ${e.message}") } } } } - fun toggleSelection(itemId: String) { + fun toggleSelection(itemId: String): Unit { repository.toggleSelection(itemId) } - fun selectItem(itemId: String) { + fun selectItem(itemId: String): Unit { repository.selectItem(itemId) } - fun deselectItem(itemId: String) { + fun deselectItem(itemId: String): Unit { repository.deselectItem(itemId) } - fun toggleGroupSelection(baseTitle: String) { + fun toggleGroupSelection(baseTitle: String): Unit { viewModelScope.launch { val groupItems = uiState.value.groupedItems[baseTitle] ?: emptyList() val selectedIds = uiState.value.selectedIds val allSelected = groupItems.all { it.id in selectedIds } val itemIds = groupItems.map { it.id } + if (allSelected) { repository.deselectItems(itemIds) } else { @@ -455,56 +445,55 @@ class LibraryViewModel @Inject constructor( } } - fun selectAll() { + fun selectAll(): Unit { repository.selectAll() } - fun clearSelection() { + fun clearSelection(): Unit { repository.clearSelection() } - fun updateSearchQuery(query: String) { + fun updateSearchQuery(query: String): Unit { _searchQuery.value = query } - fun setContentTypeFilter(contentType: ContentType?) { + fun setContentTypeFilter(contentType: ContentType?): Unit { _contentTypeFilter.value = contentType } - fun setSortMode(mode: SortMode) { + fun setSortMode(mode: SortMode): Unit { _sortMode.value = mode } - fun removeSelectedItems() { + fun removeSelectedItems(): Unit { viewModelScope.launch { - try { + runCatching { val selectedIds = repository.selectedItems.value val selectedItems = repository.libraryItems.value.filter { it.id in selectedIds } if (selectedItems.isNotEmpty()) { - selectedItems.forEach { item -> - contentRepository.clearCache(item.url) - } + selectedItems.forEach { item -> contentRepository.clearCache(item.url) } repository.removeItems(selectedIds) repository.clearSelection() } - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(error = "Failed to remove selected items: ${e.message}") } } } } - fun clearLibrary() { + fun clearLibrary(): Unit { viewModelScope.launch { - try { + runCatching { repository.clearLibrary() contentRepository.clearAllCache() - } catch (e: Exception) { + }.onFailure { e -> updateState { it.copy(error = "Failed to clear library: ${e.message}") } } } } - fun toggleSourceExpansion(sourceName: String) { + fun toggleSourceExpansion(sourceName: String): Unit { repository.toggleSourceExpansion(sourceName) } + } \ No newline at end of file diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt index 8a66d35..ca346f6 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModel.kt @@ -30,8 +30,6 @@ class ReaderViewModel @Inject constructor( // Current library item ID being read private var currentLibraryItemId: String? = null - // Throttle auto navigation to avoid repeated triggers - private var lastAutoNavigateAt: Long = 0L // Suppress auto navigation when restoring a saved position until user interacts private var suppressAutoNavUntilUserInteraction: Boolean = false private var restoredScrollPercent: Float = 0f @@ -53,21 +51,16 @@ class ReaderViewModel @Inject constructor( fontFamily = preferencesManager.fontFamily, margins = preferencesManager.margins, paragraphSpacing = preferencesManager.paragraphSpacing, - readerTheme = try { - ReaderTheme.valueOf(preferencesManager.readerTheme) - } catch (e: Exception) { - ReaderTheme.DARK - } + readerTheme = runCatching { ReaderTheme.valueOf(preferencesManager.readerTheme) }.getOrDefault(ReaderTheme.DARK) ) } // Load last read item viewModelScope.launch { - val last = libraryRepository.getCurrentlyReading() - last?.let { item -> - val loadUrl = if (item.currentChapterUrl.isNotBlank()) item.currentChapterUrl else item.url - loadContent(loadUrl, item.id) - } + libraryRepository.getCurrentlyReading()?.let { last -> + val loadUrl = last.currentChapterUrl.ifBlank { last.url } + loadContent(loadUrl, last.id) + } ?: updateState { it.copy(isLoading = false) } } } @@ -76,30 +69,29 @@ class ReaderViewModel @Inject constructor( */ data class ReaderUiState( val content: ChapterContent? = null, - val isLoading: Boolean = false, - val isNavigating: Boolean = false, // Loading next/prev chapter in background + val isLoading: Boolean = true, + val isNavigating: Boolean = false, val error: String? = null, - val toastMessage: String? = null, // Temporary message to show (Toast/Snackbar) + val toastMessage: String? = null, val scrollPosition: Float = 0f, - val scrollProgress: Int = 0, // 0-100 percentage - val scrollIndex: Int = 0, // First visible item index - val scrollOffset: Int = 0, // First visible item offset + val scrollProgress: Int = 0, + val scrollIndex: Int = 0, + val scrollOffset: Int = 0, val isScrollingDown: Boolean = true, val hasReachedQuarterScreen: Boolean = false, val canNavigateNext: Boolean = false, val canNavigatePrevious: Boolean = false, - val showControls: Boolean = false, // Show/hide bottom navigation bar - val novelName: String = "", // Novel/book title - val chapterTitle: String = "", // Current chapter title - val baseTitle: String = "", // Base title for chapter lookup - val baseNovelUrl: String = "", // URL of the novel main page - val sourceName: String = "", // Name of the source - val isPagedMode: Boolean = false, // Toggle between vertical scroll and horizontal paging - val isRtl: Boolean = true, // Right-to-Left swipe for paged mode + val showControls: Boolean = false, + val novelName: String = "", + val chapterTitle: String = "", + val baseTitle: String = "", + val baseNovelUrl: String = "", + val sourceName: String = "", + val isPagedMode: Boolean = false, + val isRtl: Boolean = true, val fullChapterList: List = emptyList(), val isChaptersLoading: Boolean = false, - val seekTrigger: Long = 0L, // Timestamp to trigger seek in UI - // Formatting Settings + val seekTrigger: Long = 0L, val fontSize: Float = 18f, val lineHeight: Float = 1.5f, val fontFamily: String = "Default", @@ -108,280 +100,263 @@ class ReaderViewModel @Inject constructor( val readerTheme: ReaderTheme = ReaderTheme.DARK ) - // Formatting update functions - - fun updateFontSize(newSize: Float) { + fun updateFontSize(newSize: Float): Unit { val size = newSize.coerceIn(12f, 32f) preferencesManager.fontSize = size updateState { it.copy(fontSize = size) } } - fun updateLineHeight(newHeight: Float) { + fun updateLineHeight(newHeight: Float): Unit { val height = newHeight.coerceIn(1.0f, 2.5f) preferencesManager.lineHeight = height updateState { it.copy(lineHeight = height) } } - fun updateFontFamily(newFamily: String) { + fun updateFontFamily(newFamily: String): Unit { preferencesManager.fontFamily = newFamily updateState { it.copy(fontFamily = newFamily) } } - fun updateMargins(newMargins: Int) { + fun updateMargins(newMargins: Int): Unit { val margins = newMargins.coerceIn(0, 64) preferencesManager.margins = margins updateState { it.copy(margins = margins) } } - fun updateParagraphSpacing(newSpacing: Float) { + fun updateParagraphSpacing(newSpacing: Float): Unit { val spacing = newSpacing.coerceIn(0.0f, 3.0f) preferencesManager.paragraphSpacing = spacing updateState { it.copy(paragraphSpacing = spacing) } } - fun updateReaderTheme(newTheme: ReaderTheme) { + fun updateReaderTheme(newTheme: ReaderTheme): Unit { preferencesManager.readerTheme = newTheme.name updateState { it.copy(readerTheme = newTheme) } } - fun clearToast() { + fun clearToast(): Unit { updateState { it.copy(toastMessage = null) } } - fun loadContent(url: String, libraryItemId: String? = null, fromBottom: Boolean = false, isSilent: Boolean = false) { + fun loadContent( + url: String, + libraryItemId: String? = null, + fromBottom: Boolean = false, + isSilent: Boolean = false + ): Unit { loadJob?.cancel() progressUpdateJob?.cancel() loadJob = viewModelScope.launch { - try { - if (url.contains("#")) { - val parts = url.split("#", limit = 2) - if (parts.size == 2) { - val basePath = parts[0] - val href = parts[1] - if (basePath.startsWith("content://") || basePath.endsWith(".epub", ignoreCase = true) || basePath.contains("epub")) { - loadEpubChapter(basePath, href, libraryItemId, fromBottom, isSilent) - return@launch - } - } - } - - val prevItemId = currentLibraryItemId - val prevContent = _uiState.value.content - if (prevItemId != null && prevContent != null) { - try { - libraryRepository.updateProgress( - itemId = prevItemId, - currentChapter = "", - progress = _uiState.value.scrollProgress, - currentChapterUrl = prevContent.url, - lastScrollProgress = _uiState.value.scrollPosition, - lastReadIndex = _uiState.value.scrollIndex, - lastReadOffset = _uiState.value.scrollOffset - ) - } catch (_: Exception) {} - } + if (handleEpubUrl(url, libraryItemId, fromBottom, isSilent)) return@launch - if (!isSilent) { - updateState { it.copy(isLoading = true, error = null) } - } else { - updateState { it.copy(error = null) } - } + saveCurrentProgress() + updateState { it.copy(isLoading = !isSilent, error = null) } - when (val result = contentRepository.loadContent(url)) { - is ContentRepository.ContentResult.Success -> { - currentLibraryItemId = libraryItemId - val content = ChapterContent( - paragraphs = result.elements, - title = result.title, - url = result.url, - nextChapterUrl = contentRepository.incrementChapterUrl(result.url), - previousChapterUrl = contentRepository.decrementChapterUrl(result.url) - ) - - val libraryItem = libraryItemId?.let { libraryRepository.getItemById(it) } - val baseTitle = libraryItem?.baseTitle?.ifBlank { null } - ?: (libraryItem?.title?.let { TextUtils.extractBaseTitle(it, ContentType.WEB) }) - ?: (content.title?.let { TextUtils.extractBaseTitle(it, ContentType.WEB) }) - ?: "" - - val novelName = baseTitle.ifBlank { content.title ?: libraryItem?.title ?: "" } - val chapterTitle = TextUtils.cleanChapterTitle(content.title, novelName).ifBlank { - libraryItem?.currentChapter ?: "" - } - - val baseNovelUrl = libraryItem?.baseNovelUrl ?: "" - val sourceName = libraryItem?.sourceName ?: "" - - val savedMode = libraryItem?.readingMode - val isPaged = if (savedMode != null) { - savedMode == ReadingMode.PAGED - } else { - TextUtils.guessIsPaged(content) - } + when (val result = contentRepository.loadContent(url)) { + is ContentRepository.ContentResult.Success -> handleLoadSuccess(result, libraryItemId, fromBottom) + is ContentRepository.ContentResult.Error -> handleLoadError(result) + } + } + } - // Determine initial scroll position - val initialIndex: Int - val initialPosition: Float - val initialProgress: Int - val initialOffset: Int - - if (libraryItem != null && !isExplicitNavigation) { - initialIndex = libraryItem.lastReadIndex - initialPosition = libraryItem.lastScrollPosition - initialProgress = libraryItem.progress - initialOffset = libraryItem.lastReadOffset - restoredScrollPercent = initialPosition - suppressAutoNavUntilUserInteraction = true - } else { - initialIndex = if (fromBottom) (content.paragraphs.size - 1).coerceAtLeast(0) else 0 - initialPosition = if (fromBottom) 100f else 0f - initialProgress = if (fromBottom) 100 else 0 - initialOffset = 0 - } + private suspend fun handleEpubUrl( + url: String, + libraryItemId: String?, + fromBottom: Boolean, + isSilent: Boolean + ): Boolean { + val parts = url.split("#", limit = 2) + if (parts.size != 2) return false + + val basePath = parts[0] + val href = parts[1] + val isEpub = basePath.startsWith("content://") || + basePath.lowercase().run { endsWith(".epub") || contains("epub") } + + return if (isEpub) { + loadEpubChapter(basePath, href, libraryItemId, fromBottom, isSilent) + true + } else false + } - updateState { - it.copy( - content = content, - isLoading = false, - isNavigating = false, - error = null, - canNavigateNext = content.hasNextChapter(), - canNavigatePrevious = content.hasPreviousChapter(), - scrollPosition = initialPosition, - scrollProgress = initialProgress, - scrollIndex = initialIndex, - scrollOffset = initialOffset, - hasReachedQuarterScreen = fromBottom || initialProgress >= 25, - novelName = novelName, - chapterTitle = chapterTitle, - baseTitle = baseTitle, - baseNovelUrl = baseNovelUrl, - sourceName = sourceName, - isPagedMode = isPaged, - fullChapterList = emptyList() - ) - } - - updateNavigationUrls() + private suspend fun saveCurrentProgress(): Unit { + val prevItemId = currentLibraryItemId ?: return + val prevContent = _uiState.value.content ?: return + + runCatching { + libraryRepository.updateProgress( + itemId = prevItemId, + currentChapter = "", + progress = _uiState.value.scrollProgress, + currentChapterUrl = prevContent.url, + lastScrollProgress = _uiState.value.scrollPosition, + lastReadIndex = _uiState.value.scrollIndex, + lastReadOffset = _uiState.value.scrollOffset + ) + } + } - libraryItem?.let { item -> - if (item.baseNovelUrl.isNotBlank() && item.sourceName.isNotBlank()) { - loadFullChapterList(item.baseNovelUrl, item.sourceName) - } - } + private suspend fun handleLoadSuccess( + result: ContentRepository.ContentResult.Success, + libraryItemId: String?, + fromBottom: Boolean + ): Unit { + val effectiveLibraryItemId = libraryItemId ?: libraryRepository.getItemByUrl(result.url)?.id + currentLibraryItemId = effectiveLibraryItemId + + val content = ChapterContent( + paragraphs = result.elements, + title = result.title, + url = result.url, + nextChapterUrl = contentRepository.incrementChapterUrl(result.url), + previousChapterUrl = contentRepository.decrementChapterUrl(result.url) + ) - libraryItemId?.let { - libraryRepository.markAsCurrentlyReading(it) - } - - isExplicitNavigation = false - } - is ContentRepository.ContentResult.Error -> { - updateState { - it.copy( - isLoading = false, - isNavigating = false, - error = result.message - ) - } - isExplicitNavigation = false - } - } - } catch (e: Exception) { - updateState { - it.copy( - isLoading = false, - isNavigating = false, - error = "Failed to load content: ${e.message}" - ) - } - isExplicitNavigation = false - } + val libraryItem = effectiveLibraryItemId?.let { libraryRepository.getItemById(it) } + val baseTitle = getBaseTitle(content, libraryItem) + val novelName = baseTitle.ifBlank { content.title ?: libraryItem?.title ?: "" } + val chapterTitle = TextUtils.cleanChapterTitle(content.title, novelName).ifBlank { + libraryItem?.currentChapter ?: "" + } + + val isPaged = libraryItem?.readingMode == ReadingMode.PAGED || (libraryItem?.readingMode == null && TextUtils.guessIsPaged(content)) + + val initialScroll = calculateInitialScroll(content, libraryItem, fromBottom) + + updateState { + it.copy( + content = content, + isLoading = false, + isNavigating = false, + error = null, + canNavigateNext = content.hasNextChapter(), + canNavigatePrevious = content.hasPreviousChapter(), + scrollPosition = initialScroll.position, + scrollProgress = initialScroll.progress, + scrollIndex = initialScroll.index, + scrollOffset = initialScroll.offset, + hasReachedQuarterScreen = fromBottom || initialScroll.progress >= 25, + novelName = novelName, + chapterTitle = chapterTitle, + baseTitle = baseTitle, + baseNovelUrl = libraryItem?.baseNovelUrl ?: "", + sourceName = libraryItem?.sourceName ?: "", + isPagedMode = isPaged, + fullChapterList = emptyList() + ) } - } - fun navigateToNextChapter() { updateNavigationUrls() - val nextUrl = _uiState.value.content?.nextChapterUrl ?: return - loadJob?.cancel() - progressUpdateJob?.cancel() - loadJob = viewModelScope.launch { - isExplicitNavigation = true - val existingNextItem = libraryRepository.getItemByUrl(nextUrl) - if (existingNextItem != null) { - loadContent(nextUrl, existingNextItem.id) - return@launch + + libraryItem?.let { item -> + if (item.baseNovelUrl.isNotBlank() && item.sourceName.isNotBlank()) { + loadFullChapterList(item.baseNovelUrl, item.sourceName) } + libraryRepository.markAsCurrentlyReading(item.id) + } - updateState { it.copy(isNavigating = true) } - val result = contentRepository.loadContent(nextUrl) - updateState { it.copy(isNavigating = false) } + isExplicitNavigation = false + } - when (result) { - is ContentRepository.ContentResult.Success -> { - val nextItemId = addChapterToLibrary(nextUrl, result.title, isNext = true) - loadContent(nextUrl, nextItemId, isSilent = true) - } - is ContentRepository.ContentResult.Error -> { - if (result.message.contains("404")) { - updateState { it.copy(toastMessage = "Next chapter not found (404)") } - } else { - loadContent(nextUrl, isSilent = false) - } - } - } + private fun handleLoadError(result: ContentRepository.ContentResult.Error): Unit { + updateState { it.copy(isLoading = false, isNavigating = false, error = result.message) } + isExplicitNavigation = false + } + + private fun getBaseTitle(content: ChapterContent, libraryItem: LibraryItem?): String { + return libraryItem?.baseTitle?.ifBlank { null } + ?: libraryItem?.title?.let { TextUtils.extractBaseTitle(it, ContentType.WEB) } + ?: content.title?.let { TextUtils.extractBaseTitle(it, ContentType.WEB) } + ?: "" + } + + private data class ScrollState( + val index: Int, + val position: Float, + val progress: Int, + val offset: Int + ) + + private fun calculateInitialScroll( + content: ChapterContent, + libraryItem: LibraryItem?, + fromBottom: Boolean + ): ScrollState { + return if (libraryItem != null && !isExplicitNavigation) { + restoredScrollPercent = libraryItem.lastScrollPosition + suppressAutoNavUntilUserInteraction = true + ScrollState( + index = libraryItem.lastReadIndex, + position = libraryItem.lastScrollPosition, + progress = libraryItem.progress, + offset = libraryItem.lastReadOffset + ) + } else { + ScrollState( + index = if (fromBottom) (content.paragraphs.size - 1).coerceAtLeast(0) else 0, + position = if (fromBottom) 100f else 0f, + progress = if (fromBottom) 100 else 0, + offset = 0 + ) } } - - fun navigateToPreviousChapter(fromBottom: Boolean = false) { + + fun navigateToNextChapter(): Unit = navigateToAdjacentChapter(isNext = true) + fun navigateToPreviousChapter(fromBottom: Boolean = false): Unit = navigateToAdjacentChapter(isNext = false, fromBottom = fromBottom) + + private fun navigateToAdjacentChapter(isNext: Boolean, fromBottom: Boolean = false): Unit { updateNavigationUrls() - val prevUrl = _uiState.value.content?.previousChapterUrl ?: return + val url = if (isNext) _uiState.value.content?.nextChapterUrl else _uiState.value.content?.previousChapterUrl + if (url == null) return + loadJob?.cancel() progressUpdateJob?.cancel() loadJob = viewModelScope.launch { isExplicitNavigation = true - val existingPrevItem = libraryRepository.getItemByUrl(prevUrl) - if (existingPrevItem != null) { - loadContent(prevUrl, existingPrevItem.id, fromBottom = fromBottom, isSilent = true) + libraryRepository.getItemByUrl(url)?.let { existingItem -> + loadContent(url, existingItem.id, fromBottom = fromBottom, isSilent = true) return@launch } updateState { it.copy(isNavigating = true) } - val result = contentRepository.loadContent(prevUrl) + val result = contentRepository.loadContent(url) updateState { it.copy(isNavigating = false) } when (result) { is ContentRepository.ContentResult.Success -> { - val prevItemId = addChapterToLibrary(prevUrl, result.title, isNext = false) - loadContent(prevUrl, prevItemId, fromBottom = fromBottom, isSilent = true) + val itemId = addChapterToLibrary(url, result.title, isNext = isNext) + loadContent(url, itemId, fromBottom = fromBottom, isSilent = true) } is ContentRepository.ContentResult.Error -> { if (result.message.contains("404")) { - updateState { it.copy(toastMessage = "Previous chapter not found (404)") } + val msg = if (isNext) "Next chapter not found (404)" else "Previous chapter not found (404)" + updateState { it.copy(toastMessage = msg) } } else { - loadContent(prevUrl, fromBottom = fromBottom, isSilent = false) + loadContent(url, fromBottom = fromBottom, isSilent = false) } } } } } - private suspend fun addChapterToLibrary(url: String, fetchedTitle: String?, isNext: Boolean): String? { + private suspend fun addChapterToLibrary( + url: String, + fetchedTitle: String?, + isNext: Boolean + ): String? { val currentItem = currentLibraryItemId?.let { libraryRepository.getItemById(it) } if (currentItem == null || currentItem.contentType != ContentType.WEB) return null - return try { + return runCatching { val title = fetchedTitle ?: url - val chapterLabel = TextUtils.extractChapterLabel(title) - ?: TextUtils.extractChapterLabelFromUrl(url) + val chapterLabel = TextUtils.extractChapterLabel(title) + ?: TextUtils.extractChapterLabelFromUrl(url) ?: (if (isNext) "Next Chapter" else "Previous Chapter") - - val baseTitle = if (currentItem.baseTitle.isNotBlank()) { - currentItem.baseTitle - } else { + + val baseTitle = currentItem.baseTitle.ifBlank { TextUtils.extractBaseTitle(currentItem.title, ContentType.WEB) } - + val newItem = libraryRepository.addItem( title = title.trim().ifBlank { "$baseTitle - $chapterLabel" }, url = url, @@ -393,166 +368,120 @@ class ReaderViewModel @Inject constructor( ) libraryRepository.updateReadingMode(newItem.id, currentItem.readingMode) newItem.id - } catch (e: Exception) { - null - } + }.getOrNull() } - - fun loadEpubChapter(epubPath: String, href: String, libraryItemId: String? = null, fromBottom: Boolean = false, isSilent: Boolean = false) { + + fun loadEpubChapter( + epubPath: String, + href: String, + libraryItemId: String? = null, + fromBottom: Boolean = false, + isSilent: Boolean = false + ): Unit { loadJob?.cancel() progressUpdateJob?.cancel() loadJob = viewModelScope.launch { - try { - val prevItemId = currentLibraryItemId - val prevContent = _uiState.value.content - if (prevItemId != null && prevContent != null) { - try { - libraryRepository.updateProgress( - itemId = prevItemId, - currentChapter = "", - progress = _uiState.value.scrollProgress, - currentChapterUrl = prevContent.url, - lastScrollProgress = _uiState.value.scrollPosition, - lastReadIndex = _uiState.value.scrollIndex, - lastReadOffset = _uiState.value.scrollOffset - ) - } catch (_: Exception) {} - } + saveCurrentProgress() - if (!isSilent) { - updateState { it.copy(isLoading = true, error = null) } - } else { - updateState { it.copy(error = null) } - } + if (!isSilent) { + updateState { it.copy(isLoading = true, error = null) } + } else { + updateState { it.copy(error = null) } + } - val epubBook = contentRepository.getEpubBook(epubPath) - if (epubBook == null) { - updateState { - it.copy( - isLoading = false, - isNavigating = false, - error = "Failed to load EPUB structure" - ) - } - return@launch - } - - currentLibraryItemId = libraryItemId - val chapter = contentRepository.loadEpubChapterFull(epubPath, href) - if (chapter == null) { - updateState { - it.copy( - isLoading = false, - isNavigating = false, - error = "Failed to load chapter content" - ) - } - return@launch - } - - val formattedElements = mutableListOf() - val textBuffer = mutableListOf() - - fun flushTextBuffer() { - if (textBuffer.isEmpty()) return - val joined = textBuffer.joinToString("\n\n") - val formatted = TextUtils.formatChapterText(joined) - val parts = formatted.split(Regex("""\n\s*\n""")).map { it.trim() }.filter { it.isNotBlank() } - parts.forEach { p -> formattedElements.add(ContentElement.Text(p)) } - textBuffer.clear() - } + val epubBook = contentRepository.getEpubBook(epubPath) + if (epubBook == null) { + handleLoadError(ContentRepository.ContentResult.Error("Failed to load EPUB structure")) + return@launch + } - for (el in chapter.content) { - when (el) { - is ContentElement.Text -> textBuffer.add(el.content) - is ContentElement.Image -> { - flushTextBuffer() - formattedElements.add(el) - } - is ContentElement.ImageGroup -> { - flushTextBuffer() - formattedElements.add(el) - } - } - } - flushTextBuffer() - - val content = ChapterContent( - paragraphs = formattedElements, - title = chapter.title, - url = "$epubPath#$href", - nextChapterUrl = chapter.nextHref?.let { "$epubPath#${it}" } - ?: epubBook.getNextHref(href)?.let { "$epubPath#${it}" }, - previousChapterUrl = chapter.previousHref?.let { "$epubPath#${it}" } - ?: epubBook.getPreviousHref(href)?.let { "$epubPath#${it}" } + val chapter = contentRepository.loadEpubChapterFull(epubPath, href) + if (chapter == null) { + handleLoadError(ContentRepository.ContentResult.Error("Failed to load chapter content")) + return@launch + } + + val effectiveLibraryItemId = libraryItemId ?: libraryRepository.getItemByUrl(epubPath)?.id + currentLibraryItemId = effectiveLibraryItemId + + val content = ChapterContent( + paragraphs = formatEpubElements(chapter.content), + title = chapter.title, + url = "$epubPath#$href", + nextChapterUrl = chapter.nextHref?.let { "$epubPath#${it}" } + ?: epubBook.getNextHref(href)?.let { "$epubPath#${it}" }, + previousChapterUrl = chapter.previousHref?.let { "$epubPath#${it}" } + ?: epubBook.getPreviousHref(href)?.let { "$epubPath#${it}" } + ) + + val libraryItem = effectiveLibraryItemId?.let { libraryRepository.getItemById(it) } + val baseTitle = libraryItem?.baseTitle?.ifBlank { null } + ?: content.title?.let { TextUtils.extractBaseTitle(it, ContentType.EPUB) } + ?: libraryItem?.title?.let { TextUtils.extractBaseTitle(it, ContentType.EPUB) } + ?: "" + + val novelName = baseTitle.ifBlank { content.title ?: libraryItem?.title ?: "" } + val chapterTitle = TextUtils.cleanChapterTitle(content.title, novelName).ifBlank { + libraryItem?.currentChapter ?: "" + } + + val initialScroll = calculateInitialScroll(content, libraryItem, fromBottom) + + updateState { + it.copy( + content = content, + isLoading = false, + isNavigating = false, + error = null, + canNavigateNext = content.hasNextChapter(), + canNavigatePrevious = content.hasPreviousChapter(), + scrollPosition = initialScroll.position, + scrollProgress = initialScroll.progress, + scrollIndex = initialScroll.index, + scrollOffset = initialScroll.offset, + hasReachedQuarterScreen = fromBottom || initialScroll.progress >= 25, + novelName = novelName, + chapterTitle = chapterTitle, + baseTitle = baseTitle ) + } - val libraryItem = libraryItemId?.let { libraryRepository.getItemById(it) } - val baseTitle = libraryItem?.baseTitle?.ifBlank { null } - ?: content.title?.let { TextUtils.extractBaseTitle(it, ContentType.EPUB) } - ?: libraryItem?.title?.let { TextUtils.extractBaseTitle(it, ContentType.EPUB) } - ?: "" - - val novelName = baseTitle.ifBlank { content.title ?: libraryItem?.title ?: "" } - val chapterTitle = TextUtils.cleanChapterTitle(content.title, novelName).ifBlank { - libraryItem?.currentChapter ?: "" - } - - // Determine initial scroll position - val initialIndex: Int - val initialPosition: Float - val initialProgress: Int - val initialOffset: Int - - if (libraryItem != null && !isExplicitNavigation) { - initialIndex = libraryItem.lastReadIndex - initialPosition = libraryItem.lastScrollPosition - initialProgress = libraryItem.progress - initialOffset = libraryItem.lastReadOffset - restoredScrollPercent = initialPosition - suppressAutoNavUntilUserInteraction = true - } else { - initialIndex = if (fromBottom) (content.paragraphs.size - 1).coerceAtLeast(0) else 0 - initialPosition = if (fromBottom) 100f else 0f - initialProgress = if (fromBottom) 100 else 0 - initialOffset = 0 - } + effectiveLibraryItemId?.let { + libraryRepository.markAsCurrentlyReading(it) + } - updateState { - it.copy( - content = content, - isLoading = false, - isNavigating = false, - error = null, - canNavigateNext = content.hasNextChapter(), - canNavigatePrevious = content.hasPreviousChapter(), - scrollPosition = initialPosition, - scrollProgress = initialProgress, - scrollIndex = initialIndex, - scrollOffset = initialOffset, - hasReachedQuarterScreen = fromBottom || initialProgress >= 25, - novelName = novelName, - chapterTitle = chapterTitle, - baseTitle = baseTitle - ) - } + isExplicitNavigation = false + } + } - libraryItemId?.let { - libraryRepository.markAsCurrentlyReading(it) - } - - isExplicitNavigation = false - } catch (e: Exception) { - updateState { - it.copy( - isLoading = false, - isNavigating = false, - error = "Failed to load EPUB chapter: ${e.message}" - ) + private fun formatEpubElements(rawElements: List): List { + val formattedElements = mutableListOf() + val textBuffer = mutableListOf() + + fun flushTextBuffer() { + if (textBuffer.isEmpty()) return + val joined = textBuffer.joinToString("\n\n") + val formatted = TextUtils.formatChapterText(joined) + val parts = formatted.split(Regex("""\n\s*\n""")).map { it.trim() }.filter { it.isNotBlank() } + parts.forEach { p -> formattedElements.add(ContentElement.Text(p)) } + textBuffer.clear() + } + + for (el in rawElements) { + when (el) { + is ContentElement.Text -> textBuffer.add(el.content) + is ContentElement.Image, is ContentElement.ImageGroup -> { + flushTextBuffer() + formattedElements.add(el) } - isExplicitNavigation = false } } + flushTextBuffer() + return formattedElements + } + + fun onUserInteraction(): Unit { + suppressAutoNavUntilUserInteraction = false } fun updateScrollPosition( @@ -561,16 +490,14 @@ class ReaderViewModel @Inject constructor( viewportHeight: Float, index: Int, offset: Int - ) { + ): Unit { val deltaRaw = if (lastRawScrollOffset < 0f) 0f else scrollOffset - lastRawScrollOffset val isScrollingDown = deltaRaw > 0f - val progress = if (maxScrollOffset > viewportHeight) { - ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn(0f, 100f) - } else if (maxScrollOffset > 0) { - 100f - } else { - 0f + val progress = when { + maxScrollOffset > viewportHeight -> ((scrollOffset / (maxScrollOffset - viewportHeight)) * 100f).coerceIn(0f, 100f) + maxScrollOffset > 0 -> 100f + else -> 0f } val hasReached = progress >= 25f @@ -589,13 +516,12 @@ class ReaderViewModel @Inject constructor( progressUpdateJob?.cancel() progressUpdateJob = viewModelScope.launch { - delay(100) // Slightly longer delay to allow layout to settle + delay(100) - if (suppressAutoNavUntilUserInteraction && progress < 99.9f) { - if (abs(progress - restoredScrollPercent) > 1f) { + if (suppressAutoNavUntilUserInteraction) { + if (abs(progress - restoredScrollPercent) < 1f) { suppressAutoNavUntilUserInteraction = false } else { - // Still in restoration phase, don't save yet lastRawScrollOffset = scrollOffset return@launch } @@ -618,9 +544,9 @@ class ReaderViewModel @Inject constructor( scrollPosition: Float? = null, index: Int? = null, offset: Int? = null - ) { - try { - currentLibraryItemId?.let { itemId -> + ): Unit { + currentLibraryItemId?.let { itemId -> + runCatching { val currentChapterUrl = _uiState.value.content?.url ?: "" val lastScroll = scrollPosition ?: _uiState.value.scrollPosition val lastIndex = index ?: _uiState.value.scrollIndex @@ -628,7 +554,7 @@ class ReaderViewModel @Inject constructor( libraryRepository.saveProgress( itemId = itemId, - currentChapter = "", + currentChapter = "", progress = progress, currentChapterUrl = currentChapterUrl, lastScrollProgress = lastScroll, @@ -636,72 +562,52 @@ class ReaderViewModel @Inject constructor( lastReadOffset = lastOffset ) } - } catch (e: Exception) {} + } } - fun clearError() { + fun clearError(): Unit { updateState { it.copy(error = null) } } - fun retryLoad() { - val url = _uiState.value.content?.url - if (url != null) { + fun retryLoad(): Unit { + _uiState.value.content?.url?.let { url -> loadContent(url, currentLibraryItemId) } } - fun resetState() { + fun resetState(): Unit { _uiState.value = ReaderUiState() currentLibraryItemId = null } - fun isContentCached(url: String): Boolean { - return contentRepository.isCached(url) - } + fun isContentCached(url: String): Boolean = contentRepository.isCached(url) - fun clearCache(url: String) { - viewModelScope.launch { - try { - contentRepository.clearCache(url) - } catch (e: Exception) {} - } + fun clearCache(url: String): Unit { + viewModelScope.launch { runCatching { contentRepository.clearCache(url) } } } - fun clearAllCache() { + fun clearAllCache(): Unit { viewModelScope.launch { - try { - contentRepository.clearAllCache() - } catch (e: Exception) { - updateState { - it.copy(error = "Failed to clear cache: ${e.message}") - } - } + runCatching { contentRepository.clearAllCache() } + .onFailure { e -> updateState { it.copy(error = "Failed to clear cache: ${e.message}") } } } } - suspend fun getCacheSize(): Long { - return contentRepository.getCacheSize() - } + suspend fun getCacheSize(): Long = contentRepository.getCacheSize() - fun saveScrollPosition(position: Float) { + fun saveScrollPosition(position: Float): Unit { updateState { it.copy(scrollPosition = position) } } - fun getScrollPosition(): Float { - return _uiState.value.scrollPosition - } + fun getScrollPosition(): Float = _uiState.value.scrollPosition - fun seekToProgress(progress: Float) { + fun seekToProgress(progress: Float): Unit { val targetPercent = progress.coerceIn(0f, 100f) val totalItems = _uiState.value.content?.paragraphs?.size ?: 0 - // Calculate index and fractional offset for better precision val preciseItemIndex = (targetPercent / 100f) * (totalItems - 1).coerceAtLeast(0) val roughIndex = preciseItemIndex.toInt().coerceIn(0, (totalItems - 1).coerceAtLeast(0)) - // We can't easily calculate pixel offset here without layout info, - // but we've improved index selection. - updateState { it.copy( scrollPosition = targetPercent, scrollProgress = targetPercent.toInt(), @@ -718,46 +624,32 @@ class ReaderViewModel @Inject constructor( ) } - override fun onCleared() { + override fun onCleared(): Unit { super.onCleared() val progress = _uiState.value.scrollProgress - if (progress >= 0) { - updateReadingProgress(progress) - } + if (progress >= 0) updateReadingProgress(progress) } - fun toggleControls() { - updateState { it.copy(showControls = !it.showControls) } - } - - fun hideControls() { - updateState { it.copy(showControls = false) } - } + fun toggleControls(): Unit = updateState { it.copy(showControls = !it.showControls) } + fun hideControls(): Unit = updateState { it.copy(showControls = false) } - fun toggleReadingMode() { + fun toggleReadingMode(): Unit { val newMode = !uiState.value.isPagedMode updateState { it.copy(isPagedMode = newMode) } currentLibraryItemId?.let { id -> viewModelScope.launch { - libraryRepository.updateReadingMode( - id, - if (newMode) ReadingMode.PAGED - else ReadingMode.VERTICAL - ) + libraryRepository.updateReadingMode(id, if (newMode) ReadingMode.PAGED else ReadingMode.VERTICAL) } } } - fun toggleRtl() { - updateState { it.copy(isRtl = !it.isRtl) } - } + fun toggleRtl(): Unit = updateState { it.copy(isRtl = !it.isRtl) } - fun navigateToChapter(url: String, title: String) { + fun navigateToChapter(url: String, title: String): Unit { loadJob?.cancel() loadJob = viewModelScope.launch { isExplicitNavigation = true - val existingItem = libraryRepository.getItemByUrl(url) - if (existingItem != null) { + libraryRepository.getItemByUrl(url)?.let { existingItem -> loadContent(url, existingItem.id) return@launch } @@ -772,44 +664,33 @@ class ReaderViewModel @Inject constructor( is ContentRepository.ContentResult.Error -> { if (result.message.contains("404")) { updateState { it.copy(toastMessage = "Chapter not found (404)") } - } else { - loadContent(url, isSilent = false) - } + } else loadContent(url, isSilent = false) } } } } - fun loadFullChapterList(baseUrl: String, sourceName: String) { + fun loadFullChapterList(baseUrl: String, sourceName: String): Unit { viewModelScope.launch { - try { + runCatching { updateState { it.copy(isChaptersLoading = true) } val details = exploreRepository.getNovelDetails(baseUrl, sourceName) if (details != null && details.chapters.isNotEmpty()) { - updateState { - it.copy( - fullChapterList = details.chapters, - isChaptersLoading = false - ) - } + updateState { it.copy(fullChapterList = details.chapters, isChaptersLoading = false) } updateNavigationUrls() currentLibraryItemId?.let { id -> - val item = libraryRepository.getItemById(id) - if (item != null && item.totalChapters != details.chapters.size) { - libraryRepository.updateItem(item.copy(totalChapters = details.chapters.size)) + libraryRepository.getItemById(id)?.let { item -> + if (item.totalChapters != details.chapters.size) { + libraryRepository.updateItem(item.copy(totalChapters = details.chapters.size)) + } } } - } else { - updateState { it.copy(isChaptersLoading = false) } - } - } - catch (e: Exception) { - updateState { it.copy(isChaptersLoading = false) } - } + } else updateState { it.copy(isChaptersLoading = false) } + }.onFailure { updateState { it.copy(isChaptersLoading = false) } } } } - private fun updateNavigationUrls() { + private fun updateNavigationUrls(): Unit { val state = _uiState.value val currentUrl = state.content?.url ?: return val list = state.fullChapterList diff --git a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/SummaryViewModel.kt b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/SummaryViewModel.kt index 34279e3..8ee5394 100644 --- a/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/SummaryViewModel.kt +++ b/app/src/main/java/io/aatricks/novelscraper/ui/viewmodel/SummaryViewModel.kt @@ -35,23 +35,20 @@ class SummaryViewModel @Inject constructor( /** * Initialize the summary service (loads AI model) */ - fun initializeSummaryService() { + fun initializeSummaryService(): Unit { viewModelScope.launch { updateState { it.copy(isInitializing = true, error = null) } - val result = summaryService.initialize() - - if (result.isSuccess) { - Log.d(TAG, "Summary service initialized successfully") - updateState { it.copy(isInitializing = false) } - } else { - val error = result.exceptionOrNull()?.message ?: "Failed to initialize" - Log.e(TAG, "Summary service initialization failed: $error") - updateState { it.copy( - isInitializing = false, - error = error - ) } - } + summaryService.initialize() + .onSuccess { + Log.d(TAG, "Summary service initialized successfully") + updateState { it.copy(isInitializing = false) } + } + .onFailure { e -> + val error = e.message ?: "Failed to initialize" + Log.e(TAG, "Summary service initialization failed: $error") + updateState { it.copy(isInitializing = false, error = error) } + } } } @@ -63,9 +60,8 @@ class SummaryViewModel @Inject constructor( chapterTitle: String?, content: List, onComplete: (String) -> Unit - ) { - val cached = _uiState.value.summariesCache[chapterUrl] - if (cached != null) { + ): Unit { + _uiState.value.summariesCache[chapterUrl]?.let { cached -> updateState { it.copy(currentSummary = cached) } onComplete(cached) return @@ -80,48 +76,58 @@ class SummaryViewModel @Inject constructor( ) } val sb = StringBuilder() - val result = summaryService.generateSummary(chapterTitle, content, onProgress = { token -> + summaryService.generateSummary(chapterTitle, content, onProgress = { token -> sb.append(token) updateState { it.copy(currentSummary = sb.toString()) } - }) - - if (result.isSuccess) { - val summary = result.getOrNull() ?: "Summary generated" - val updatedCache = _uiState.value.summariesCache.toMutableMap() - updatedCache[chapterUrl] = summary - - updateState { it.copy( - isGenerating = false, - activeChapterUrl = null, - currentSummary = summary, - summariesCache = updatedCache - ) } - onComplete(summary) - } else { - val error = result.exceptionOrNull()?.message ?: "Failed to generate summary" - updateState { it.copy( - isGenerating = false, - activeChapterUrl = null, - error = error - ) } + }).onSuccess { summary -> + handleGenerationSuccess(chapterUrl, summary, onComplete) + }.onFailure { e -> + handleGenerationFailure(e) } } } - fun cancelGeneration() { - try { io.aatricks.llmedge.LLMEdgeManager.cancelGeneration() } catch (_: Throwable) {} + private fun handleGenerationSuccess( + chapterUrl: String, + summary: String, + onComplete: (String) -> Unit + ): Unit { + val updatedCache = _uiState.value.summariesCache.toMutableMap().apply { + put(chapterUrl, summary) + } + + updateState { it.copy( + isGenerating = false, + activeChapterUrl = null, + currentSummary = summary, + summariesCache = updatedCache + ) } + onComplete(summary) + } + + private fun handleGenerationFailure(e: Throwable): Unit { + val error = e.message ?: "Failed to generate summary" + updateState { it.copy( + isGenerating = false, + activeChapterUrl = null, + error = error + ) } + } + + fun cancelGeneration(): Unit { + runCatching { io.aatricks.llmedge.LLMEdgeManager.cancelGeneration() } updateState { it.copy(isGenerating = false, activeChapterUrl = null) } } fun getCachedSummary(chapterUrl: String): String? = _uiState.value.summariesCache[chapterUrl] - fun clearError() { + fun clearError(): Unit { updateState { it.copy(error = null) } } fun isServiceReady(): Boolean = summaryService.isReady() - override fun onCleared() { + override fun onCleared(): Unit { super.onCleared() summaryService.release() } diff --git a/app/src/main/java/io/aatricks/novelscraper/util/FileUtils.kt b/app/src/main/java/io/aatricks/novelscraper/util/FileUtils.kt index 699de55..25913ad 100644 --- a/app/src/main/java/io/aatricks/novelscraper/util/FileUtils.kt +++ b/app/src/main/java/io/aatricks/novelscraper/util/FileUtils.kt @@ -15,43 +15,27 @@ object FileUtils { /** * Get the filename from a URI - * @param context Application context - * @param uri The URI to extract filename from - * @return The filename or null if not found */ fun getFileName(context: Context, uri: Uri): String? { - var result: String? = null - - if (uri.scheme == "content") { - var cursor: Cursor? = null - try { - cursor = context.contentResolver.query(uri, null, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0) { - result = cursor.getString(nameIndex) - } - } - } catch (e: Exception) { - e.printStackTrace() - } finally { - cursor?.close() - } - } + if (uri.scheme != "content") return uri.lastPathSegment - // Fallback to last path segment if content scheme fails - if (result == null) { - result = uri.lastPathSegment - } + return queryContentColumn(context, uri, OpenableColumns.DISPLAY_NAME) + ?: uri.lastPathSegment + } - return result + private fun queryContentColumn(context: Context, uri: Uri, columnName: String): String? { + return runCatching { + context.contentResolver.query(uri, arrayOf(columnName), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(columnName) + if (index >= 0) cursor.getString(index) else null + } else null + } + }.getOrNull() } /** * Get the file extension from a URI - * @param context Application context - * @param uri The URI to extract extension from - * @return The file extension (without dot) or empty string */ fun getFileExtension(context: Context, uri: Uri): String { val fileName = getFileName(context, uri) ?: return "" @@ -65,9 +49,6 @@ object FileUtils { /** * Get MIME type from URI - * @param context Application context - * @param uri The URI to get MIME type from - * @return MIME type string or null */ fun getMimeType(context: Context, uri: Uri): String? { return context.contentResolver.getType(uri) @@ -75,9 +56,6 @@ object FileUtils { /** * Detect file type from URI based on MIME type and extension - * @param context Application context - * @param uri The URI to detect type from - * @return FileType enum value */ fun detectFileType(context: Context, uri: Uri): FileType { val mimeType = getMimeType(context, uri) @@ -97,72 +75,42 @@ object FileUtils { /** * Read InputStream from URI - * @param context Application context - * @param uri The URI to read from - * @return InputStream or null if failed */ fun getInputStream(context: Context, uri: Uri): InputStream? { - return try { - context.contentResolver.openInputStream(uri) - } catch (e: Exception) { - e.printStackTrace() - null - } + return runCatching { context.contentResolver.openInputStream(uri) }.getOrNull() } /** * Copy URI content to a file - * @param context Application context - * @param uri Source URI - * @param destinationFile Destination file - * @return true if successful, false otherwise */ fun copyUriToFile(context: Context, uri: Uri, destinationFile: File): Boolean { - return try { - val inputStream = getInputStream(context, uri) ?: return false - inputStream.use { input -> + return runCatching { + getInputStream(context, uri)?.use { input -> destinationFile.outputStream().use { output -> input.copyTo(output) } - } - true - } catch (e: Exception) { - e.printStackTrace() - false - } + } != null + }.getOrDefault(false) } /** * Get file size from URI - * @param context Application context - * @param uri The URI to get size from - * @return File size in bytes or -1 if unknown */ fun getFileSize(context: Context, uri: Uri): Long { - var size: Long = -1 - if (uri.scheme == "content") { - var cursor: Cursor? = null - try { - cursor = context.contentResolver.query(uri, null, null, null, null) - if (cursor != null && cursor.moveToFirst()) { - val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) - if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) { - size = cursor.getLong(sizeIndex) - } - } - } catch (e: Exception) { - e.printStackTrace() - } finally { - cursor?.close() + if (uri.scheme != "content") return -1L + + return runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.SIZE) + if (index >= 0 && !cursor.isNull(index)) cursor.getLong(index) else -1L + } else -1L } - } - return size + }.getOrNull() ?: -1L } /** * Format file size to human-readable string - * @param bytes Size in bytes - * @return Formatted string (e.g., "1.5 MB") */ fun formatFileSize(bytes: Long): String { if (bytes < 0) return "Unknown" @@ -176,13 +124,11 @@ object FileUtils { unitIndex++ } - return String.format("%.2f %s", size, units[unitIndex]) + return "%.2f %s".format(size, units[unitIndex]) } /** * Check if URI is a local file - * @param uri The URI to check - * @return true if local file, false otherwise */ fun isLocalFile(uri: Uri): Boolean { return uri.scheme == "file" @@ -190,8 +136,6 @@ object FileUtils { /** * Check if URI is a content URI - * @param uri The URI to check - * @return true if content URI, false otherwise */ fun isContentUri(uri: Uri): Boolean { return uri.scheme == "content" @@ -199,8 +143,6 @@ object FileUtils { /** * Check if URI is a remote URL - * @param uri The URI to check - * @return true if remote URL, false otherwise */ fun isRemoteUrl(uri: Uri): Boolean { return uri.scheme == "http" || uri.scheme == "https" @@ -208,16 +150,12 @@ object FileUtils { /** * Validate if a string is a valid URL - * @param url The URL string to validate - * @return true if valid URL, false otherwise */ fun isValidUrl(url: String): Boolean { - return try { + return runCatching { val uri = Uri.parse(url) uri.scheme != null && (uri.scheme == "http" || uri.scheme == "https") - } catch (e: Exception) { - false - } + }.getOrDefault(false) } /** diff --git a/app/src/main/java/io/aatricks/novelscraper/util/TextUtils.kt b/app/src/main/java/io/aatricks/novelscraper/util/TextUtils.kt index 01f9c0e..7eda3fe 100644 --- a/app/src/main/java/io/aatricks/novelscraper/util/TextUtils.kt +++ b/app/src/main/java/io/aatricks/novelscraper/util/TextUtils.kt @@ -1,7 +1,6 @@ package io.aatricks.novelscraper.util import java.net.URI -import java.util.regex.Pattern /** * Utility functions for text processing and manipulation. @@ -13,43 +12,40 @@ object TextUtils { private val DIGIT_REGEX = Regex("\\d+") private val PAGE_WORD_REGEX = Regex("Page \\|\\s*|Page\\s+") private val WHITESPACE_REGEX = Regex("\\s+") - private val MULTIPLE_SPACES_REGEX = Regex(" +") + private val MULTIPLE_SPACES_REGEX = Regex(" +\n") private val LINE_BREAK_REGEX = Regex("\\r\\n|\\r") - private val SPACE_PLUS_NEWLINE_REGEX = Regex(" +\n") - private val FOUR_PLUS_NEWLINES_REGEX = Regex("\n{4,}") - private val THREE_PLUS_NEWLINES_REGEX = Regex("\n{3,}") - private val PARAGRAPH_SPLIT_REGEX = Regex("(?s)(.*?)(\\n{2,}|$)") - private val LIST_MARKER_REGEX = Regex("^(\\d+\\.|[ivxIVX]+\\.|[-*•])\\s") + private val SPACE_PLUS_NEWLINE_REGEX = Regex(" +\\n") + private val FOUR_PLUS_NEWLINES_REGEX = Regex("\\n{4,}") + private val THREE_PLUS_NEWLINES_REGEX = Regex("\\n{3,}") + private val PARAGRAPH_SPLIT_REGEX = Regex("(?s)(.*?)(\\n+|$)") + private val LIST_MARKER_REGEX = Regex("^(?:\\d+|[ivxIVX]+\\.|[-*•])\\s") private val NEWLINE_BEFORE_LOWER_DIGIT_REGEX = Regex("\\n(?=[a-z0-9])") private val SINGLE_NEWLINE_REGEX = Regex("(? 1) matcher.replaceFirst((num - 1).toString()) else url + if (num > 1) url.replaceRange(match.range, (num - 1).toString()) else url } else url } + /** * Extract title from URL path - * Gets the last non-empty path segment and formats it - * - * @param url The URL to extract title from - * @return Extracted and formatted title */ fun extractTitleFromUrl(url: String): String { if (url.isEmpty()) return "Unknown" - - return try { + return runCatching { val uri = URI(url) - val path = uri.path - val pathSegments = path.split("/").filter { it.isNotEmpty() } + val lastSegment = uri.path.split("/").filter { it.isNotEmpty() }.lastOrNull() - // Get the last non-empty segment - val lastSegment = pathSegments.lastOrNull() - - if (lastSegment != null) { - // Replace hyphens and underscores with spaces - // Capitalize first letter of each word - lastSegment - .replace("-", " ") + lastSegment?.let { segment -> + segment.replace("-", " ") .replace("_", " ") .split(" ") .filter { it.isNotBlank() } - .joinToString(" ") { word -> - word.lowercase().replaceFirstChar { it.uppercase() } - } - } else { - uri.host ?: "Unknown" - } - } catch (e: Exception) { - "Unknown" - } + .joinToString(" ") { it.lowercase().replaceFirstChar { c -> c.uppercase() } } + } ?: uri.host ?: "Unknown" + }.getOrDefault("Unknown") } /** * Extract base title by removing chapter markers and common web junk. - * Only normalizes WEB content - PDFs/HTML/EPUB keep full titles. */ fun extractBaseTitle(title: String, contentType: io.aatricks.novelscraper.data.model.ContentType): String { - // Only normalize WEB content for grouping if (contentType != io.aatricks.novelscraper.data.model.ContentType.WEB) return title - var normalized = title - - // 1. Remove common web novel "junk" first - val junkPatterns = listOf( - Regex("""(?i)^read\s+"""), - Regex("""(?i)\s+free\s+online.*$"""), - Regex("""(?i)\s+online\s+free.*$"""), - Regex("""(?i)\s*\|\s*.*$"""), // Remove anything after | - Regex("""(?i)\s+at\s+.*$"""), // Remove " at SourceName" - Regex("""(?i)[\s–—\-:]*(MangaBat|NovelFire|MangaPark|MangaKakalot).*$"""), - Regex("""(?i)[\s–—\-:]*Scan.*$""") + var normalized = removeCommonJunk(title) + normalized = removeChapterMarkers(normalized) + normalized = cleanSeparators(normalized) + + return if (normalized.isBlank() || normalized.length < 3) title else normalized + } + + private fun removeCommonJunk(text: String): String { + val patterns = listOf( + Regex("(?i)^read\\s+"), + Regex("(?i)\\s+free\\s+online.*\$"), + Regex("(?i)\\s+online\\s+free.*\$"), + Regex("(?i)\\s*\\|\\s*.*\$"), + Regex("(?i)\\s+at\\s+.*\$"), + Regex("(?i)[\\s–—\\-:]*(MangaBat|NovelFire|MangaPark|MangaKakalot).*\$"), + Regex("(?i)[\\s–—\\-:]*Scan.*\$") ) - - for (pattern in junkPatterns) { - normalized = normalized.replace(pattern, "") - } - - // 2. Remove common chapter markers and trailing content - val chapterPatterns = listOf( - Regex("""[–—\-:]?\s*(?:chapter|ch|ch\.)\s*\d+.*$""", RegexOption.IGNORE_CASE), - Regex("""\s*[–—\-]\s*\d+.*$"""), // "Title - 123" or "Title – 123" - Regex("""\s*:\s*\d+.*$""") // "Title: 123" + return patterns.fold(text) { acc, pattern -> acc.replace(pattern, "") } + } + + private fun removeChapterMarkers(text: String): String { + val patterns = listOf( + Regex("[–—\\-:]?\\s*(?:chapter|ch|ch\\.)\\s*\\d+.*$", RegexOption.IGNORE_CASE), + Regex("\\s*[–—\\-]\\s*\\d+.*$"), + Regex("\\s*:\\s*\\d+.*$") ) - for (pattern in chapterPatterns) { - normalized = normalized.replace(pattern, "").trim() - } + return patterns.fold(text) { acc, pattern -> acc.replace(pattern, "").trim() } + } - // 3. Final cleanup of separators - normalized = normalized.replace(Regex("""^[\s–—\-:\|]+"""), "") - .replace(Regex("""[\s–—\-:\|]+$"""), "") + private fun cleanSeparators(text: String): String { + return text.replace(Regex("^[\\s–—\\-:\\|]+"), "") + .replace(Regex("[\\s–—\\-:\\|]+$"), "") .trim() - - return if (normalized.isBlank() || normalized.length < 3) title else normalized } /** - * Extract a standardized chapter label (e.g., "Chapter 233") from text. + * Extract a standardized chapter label from text. */ fun extractChapterLabel(title: String?): String? { - if (title == null || title.isBlank()) return null + if (title.isNullOrBlank()) return null - // Priority 1: Explicit chapter markers - val regex = Regex("""(?i)(?:chapter|ch|ch\.|c)\s*(\d+)""") - val match = regex.find(title) - if (match != null) { - return "Chapter ${match.groupValues[1]}" + Regex("(?i)(?:chapter|ch|ch\\.|c)\\s*(\\d+)").find(title)?.let { + return "Chapter " + it.groupValues[1] } - // Priority 2: Number after a separator at the end of the string - // e.g. "Novel Title: 150" or "Novel Title - 150" - val endNumberRegex = Regex("""[\s:\-—–\|](\d+)\s*$""") - val endMatch = endNumberRegex.find(title) - if (endMatch != null) { - return "Chapter ${endMatch.groupValues[1]}" + Regex("[\\s:\\-—–|](\\d+)\\s*$").find(title)?.let { + return "Chapter " + it.groupValues[1] } - // Priority 3: Any standalone number that isn't part of the title's year or volume - // We look for the last number in the string as it's most likely the chapter - val allNumbers = Regex("""\b(\d+)\b""").findAll(title) - val lastNumberMatch = allNumbers.lastOrNull() - if (lastNumberMatch != null) { - val num = lastNumberMatch.groupValues[1] - // Heuristic: chapter numbers are usually not years (like 2023) unless the novel is very long - // and usually not single digits if there's a better match, but we just take the last one. - return "Chapter $num" + return Regex("\\b(\\d+)\\b").findAll(title).lastOrNull()?.let { + "Chapter " + it.groupValues[1] } - - return null } /** @@ -209,396 +169,261 @@ object TextUtils { Regex("chapter\\s*(\\d+)", RegexOption.IGNORE_CASE), Regex("ch(?:apter)?\\D*(\\d+)", RegexOption.IGNORE_CASE), Regex("/(\\d+)(?:/|$)"), - Regex("-(\\d+)(?:\\D|$)") + Regex("-" + "(\\d+)(?:\\D|$)") ) - for (r in patterns) { - val m = r.find(url) - if (m != null && m.groupValues.size >= 2) { - val num = m.groupValues[1] - return "Chapter $num" - } + return patterns.firstNotNullOfOrNull { r -> + r.find(url)?.groupValues?.get(1)?.let { "Chapter " + it } } - return null } /** * Extract chapter number from URL or text - * - * @param text The text or URL to extract chapter from - * @return Chapter number or null if not found */ fun extractChapterNumber(text: String): Int? { if (text.isEmpty()) return null - - // Try to find chapter number with various patterns - for (pattern in CHAPTER_NUMBER_PATTERNS) { - val matcher = pattern.matcher(text) - if (matcher.find()) { - return matcher.group(1)?.toIntOrNull() - } + return CHAPTER_NUMBER_REGEXES.firstNotNullOfOrNull { r -> + r.find(text)?.groupValues?.get(1)?.toIntOrNull() } - - return null } /** * Format text for better readability - * - Removes extra whitespace - * - Normalizes line breaks - * - Ensures proper paragraph spacing - * - * @param text The text to format - * @return Formatted text */ fun formatText(text: String): String { if (text.isEmpty()) return text - - return text - // Remove multiple spaces - .replace(MULTIPLE_SPACES_REGEX, " ") - // Normalize line breaks + return text.replace(MULTIPLE_SPACES_REGEX, " ") .replace(LINE_BREAK_REGEX, "\n") - // Remove spaces at line ends .replace(SPACE_PLUS_NEWLINE_REGEX, "\n") - // Remove multiple consecutive newlines (keep max 3 for paragraph breaks) - .replace(FOUR_PLUS_NEWLINES_REGEX, "\\n\\n\\n") + .replace(FOUR_PLUS_NEWLINES_REGEX, "\n\n\n") .trim() } /** * Clean HTML entities from text - * - * @param text The text containing HTML entities - * @return Text with entities decoded */ fun cleanHtmlEntities(text: String): String { if (text.isEmpty()) return text - - var result = text - result = result.replace(" ", " ") - result = result.replace("&", "&") - result = result.replace("<", "<") - result = result.replace(">", ">") - result = result.replace(""", "\"") - result = result.replace("'", "'") - result = result.replace("—", "—") - result = result.replace("–", "–") - result = result.replace("…", "…") - return result + val replacements = mapOf( + " " to " ", "&" to "&", "<" to "<", ">" to ">", + """ to "\"", "'" to "'" , "—" to "—", "–" to "–", "…" to "…" + ) + return replacements.entries.fold(text) { acc, (k, v) -> acc.replace(k, v) } } /** * Truncate text to a maximum length with ellipsis - * - * @param text The text to truncate - * @param maxLength Maximum length (including ellipsis) - * @return Truncated text */ fun truncate(text: String, maxLength: Int): String { - if (text.length <= maxLength) return text - return text.take(maxLength - 3) + "..." + return if (text.length <= maxLength) text else text.take(maxLength - 3) + "..." } /** * Count words in text - * - * @param text The text to count words in - * @return Word count */ fun countWords(text: String): Int { - if (text.isEmpty()) return 0 - return text.trim().split(WHITESPACE_REGEX).size + return if (text.isEmpty()) 0 else text.trim().split(WHITESPACE_REGEX).size } /** * Estimate reading time in minutes - * Based on average reading speed of 200 words per minute - * - * @param text The text to estimate reading time for - * @return Estimated reading time in minutes */ fun estimateReadingTime(text: String): Int { - val wordCount = countWords(text) - return maxOf(1, (wordCount / 200.0).toInt()) + return maxOf(1, (countWords(text) / 200.0).toInt()) } /** - * Formats the text of a chapter by removing extra whitespace and normalizing paragraph breaks. + * Formats the text of a chapter */ fun formatChapterText(text: String): String { if (text.isEmpty()) return text - val normalized = text.trim().replace(LINE_BREAK_REGEX, "\n") + + val rawParagraphs = initialParagraphSplit(normalized) + val initialMerged = mergeAccidentalSplits(rawParagraphs, normalized) + val compacted = compactParagraphs(initialMerged, normalized) + val processed = processIndividualParagraphs(compacted) + + return finalCollapse(processed, normalized) + } - val rawParagraphsWithSep = PARAGRAPH_SPLIT_REGEX.findAll(normalized) + private fun initialParagraphSplit(text: String): List> = + PARAGRAPH_SPLIT_REGEX.findAll(text) .map { it.groupValues[1] to it.groupValues[2].length } .toList() - var rawParagraphs = rawParagraphsWithSep.map { it.first } - // Initial merge of accidental splits - val paragraphs = mutableListOf() + private fun mergeAccidentalSplits(paragraphs: List>, original: String): List { + val result = mutableListOf() var i = 0 - while (i < rawParagraphs.size) { - var cur = rawParagraphs[i].trim() - if (cur.isEmpty()) { i++; continue } - - if (i + 1 < rawParagraphs.size) { - val next = rawParagraphs[i + 1].trim() - if (rawParagraphsWithSep[i].second >= 2) { - paragraphs.add(cur) + while (i < paragraphs.size) { + val p = paragraphs[i++] + var cur = p.first.trim() + val sepCount = p.second + + if (sepCount < 2 && i < paragraphs.size) { + val next = paragraphs[i].first.trim() + if (next.isNotEmpty() && shouldMerge(cur, next)) { + cur = (cur + " " + next).replace(MULTIPLE_SPACES_REGEX, " ") i++ - continue - } - if (next.isNotEmpty()) { - val lastChar = cur.lastOrNull() - val lastW = lastWord(cur).lowercase() - val wordCount = cur.split(WHITESPACE_REGEX).size - - val shouldMerge = (lastChar != null && !SENTENCE_ENDERS.contains(lastChar)) && - (wordCount <= 8 || lastW in CONTINUATION_WORDS || lastW.length <= 4) && - !(cur.contains(':') && next.contains(':')) - - val nextFirstChar = next.firstOrNull() - val nextWordCount = next.split(WHITESPACE_REGEX).filter { it.isNotBlank() }.size - val looksLikeHeading = nextFirstChar != null && nextFirstChar.isUpperCase() && nextWordCount in 1..4 && - (next.uppercase() == next || next.trimEnd().endsWith(":")) - - if (shouldMerge && !looksLikeHeading) { - cur = (cur + " " + next).replace(MULTIPLE_SPACES_REGEX, " ") - i += 2 - while (i < rawParagraphs.size) { - val peek = rawParagraphs[i].trim() - if (peek.isEmpty()) { i++; continue } - val peekFirst = peek.firstOrNull() - if (peekFirst != null && peekFirst.isUpperCase() && cur.trim().lastOrNull()?.let { SENTENCE_ENDERS.contains(it) } == true) break - cur = (cur + " " + peek).replace(MULTIPLE_SPACES_REGEX, " ") - i++ - } - paragraphs.add(cur) - continue + + while (i < paragraphs.size) { + val peek = paragraphs[i].first.trim() + if (peek.isEmpty()) { i++; continue } + if (shouldStopGreedyMerge(cur, peek)) break + cur = (cur + " " + peek).replace(MULTIPLE_SPACES_REGEX, " ") + i++ } } } - paragraphs.add(cur) - i++ + if (cur.isNotEmpty()) result.add(cur) } + return result + } + + private fun shouldMerge(cur: String, next: String): Boolean { + val lastChar = cur.lastOrNull() ?: return false + val lastW = lastWord(cur).lowercase() + val wordCount = cur.split(WHITESPACE_REGEX).size + + return !SENTENCE_ENDERS.contains(lastChar) && + (wordCount <= 8 || lastW in CONTINUATION_WORDS || lastW.length <= 4) && + !isHeading(next) && !(cur.contains(':') && next.contains(':')) + } + + private fun isHeading(text: String): Boolean { + val firstChar = text.firstOrNull() ?: return false + val words = text.split(WHITESPACE_REGEX).filter { it.isNotBlank() } + val isAllUpper = text.uppercase() == text + return firstChar.isUpperCase() && words.size in 1..4 && (isAllUpper || text.trimEnd().endsWith(":")) + } - // Conservative aggressive merge + private fun shouldStopGreedyMerge(cur: String, peek: String): Boolean { + val peekFirst = peek.firstOrNull() ?: return true + val lastChar = cur.trim().lastOrNull() ?: ' ' + return peekFirst.isUpperCase() && SENTENCE_ENDERS.contains(lastChar) + } + + private fun compactParagraphs(paragraphs: List, original: String): List { val compacted = mutableListOf() var pi = 0 while (pi < paragraphs.size) { - var cur = paragraphs[pi].trim() - if (cur.isEmpty()) { pi++; continue } + var cur = paragraphs[pi++].trim() - while (pi + 1 < paragraphs.size) { - val nxt = paragraphs[pi + 1].trim() + while (pi < paragraphs.size) { + val nxt = paragraphs[pi].trim() if (nxt.isEmpty()) { pi++; continue } - - if (normalized.contains(cur + "\n\n" + nxt)) break - - val lastChar = cur.lastOrNull() - val lastW = lastWord(cur).lowercase() - val wordCount = cur.split(WHITESPACE_REGEX).size - val nextFirst = nxt.firstOrNull() - val nextWordCountAgg = nxt.split(WHITESPACE_REGEX).filter { it.isNotBlank() }.size - val looksLikeHeadingAgg = nextFirst != null && nextFirst.isUpperCase() && nextWordCountAgg in 1..4 && - (nxt.uppercase() == nxt || nxt.trimEnd().endsWith(":")) - - val shouldMergeAggressive = (lastChar != null && !SENTENCE_ENDERS.contains(lastChar)) && - (wordCount <= 10 || lastW in CONTINUATION_WORDS || lastW.length <= 4) && !looksLikeHeadingAgg && - !(cur.contains(':') && nxt.contains(':')) - - if (shouldMergeAggressive) { + + val shouldMerge = shouldMergeAggressive(cur, nxt) && !original.contains(cur + "\n\n" + nxt) + if (shouldMerge) { cur = (cur + " " + nxt).replace(MULTIPLE_SPACES_REGEX, " ") pi++ - continue - } - break - } - compacted.add(cur) - pi++ - } - - val processedParagraphs = compacted.map { paragraph -> - val p = paragraph.trim() - if (p.isEmpty()) return@map "" - - var builder = StringBuilder(p.replace(MULTIPLE_SPACES_REGEX, " ")) - var j = 0 - while (j < builder.length) { - val c = builder[j] - if (c == '\n') { - var prevIndex = j - 1 - while (prevIndex >= 0 && builder[prevIndex].isWhitespace()) prevIndex-- - val prevChar = if (prevIndex >= 0) builder[prevIndex] else null - - var nextIndex = j + 1 - while (nextIndex < builder.length && builder[nextIndex].isWhitespace()) nextIndex++ - val nextChar = if (nextIndex < builder.length) builder[nextIndex] else null - - val nextLineEnd = builder.indexOf('\n', nextIndex).let { if (it == -1) builder.length else it } - val nextLineSnippet = if (nextIndex < builder.length) builder.substring(nextIndex, minOf(nextLineEnd, nextIndex + 60)).trimStart() else "" - - val startsWithQuoteOrDash = nextLineSnippet.startsWith("\"") || nextLineSnippet.startsWith("“") || - nextLineSnippet.startsWith("—") || nextLineSnippet.startsWith("-") || nextLineSnippet.startsWith("'") - val nextLineWords = nextLineSnippet.split(WHITESPACE_REGEX).filter { it.isNotBlank() }.size - val looksLikeHeading = nextLineWords in 1..4 && nextLineSnippet.firstOrNull()?.isUpperCase() == true && - (nextLineSnippet.uppercase() == nextLineSnippet || nextLineSnippet.trimEnd().endsWith(":")) - val preserveBecauseNextLine = LIST_MARKER_REGEX.containsMatchIn(nextLineSnippet) || startsWithQuoteOrDash || looksLikeHeading - - when { - prevChar == null -> { - builder.deleteCharAt(j) - continue - } - prevChar == '-' -> { - builder.deleteCharAt(j) - builder.deleteCharAt(prevIndex) - j = maxOf(0, prevIndex) - continue - } - SENTENCE_ENDERS.contains(prevChar) || preserveBecauseNextLine -> { - var k = j - 1 - while (k >= 0 && builder[k].isWhitespace()) { builder.deleteCharAt(k); k--; j-- } - var m = j + 1 - while (m < builder.length && builder[m].isWhitespace()) { builder.deleteCharAt(m) } - if (j >= builder.length || builder[j] != '\n') continue - j++ - continue - } - else -> { - builder.deleteCharAt(j) - val pChar = prevChar!! - val needSpace = !pChar.isWhitespace() && pChar != '-' && - (nextChar != null && !nextChar.isWhitespace() && nextChar != ',' && nextChar != '.') - if (needSpace) { - builder.insert(j, ' ') - j++ - } - continue - } - } - } - j++ + } else break } - builder.toString().replace(NEWLINE_BEFORE_LOWER_DIGIT_REGEX, " ") + if (cur.isNotEmpty()) compacted.add(cur) } + return compacted + } - val joined = processedParagraphs.joinToString("\n\n").replace(THREE_PLUS_NEWLINES_REGEX, "\n\n") - - val parts = joined.split("\n\n").map { it.trim() }.toMutableList() - var pi2 = 0 - while (pi2 < parts.size - 1) { - val left = parts[pi2] - val right = parts[pi2 + 1] - if (left.isEmpty() || right.isEmpty()) { pi2++; continue } - - if (normalized.contains(left + "\n\n" + right)) { pi2++; continue } - - val lastChar = left.lastOrNull() - val lastW = lastWord(left).lowercase() - val leftWordCount = left.split(WHITESPACE_REGEX).size - val continuationWords2 = setOf("of", "to", "for", "and", "but", "or", "the", "a", "an") - - val shouldCollapseParagraph = (lastChar != null && !SENTENCE_ENDERS.contains(lastChar)) && - (leftWordCount <= 10 || lastW in continuationWords2 || lastW.length <= 4) + private fun shouldMergeAggressive(cur: String, next: String): Boolean { + val lastChar = cur.lastOrNull() ?: return false + val lastW = lastWord(cur).lowercase() + val wordCount = cur.split(WHITESPACE_REGEX).size + val isSentenceEnd = SENTENCE_ENDERS.contains(lastChar) + + return !isSentenceEnd && + (wordCount <= 10 || lastW in CONTINUATION_WORDS || lastW.length <= 4) && + !isHeading(next) && !(cur.contains(':') && next.contains(':')) + } - if (shouldCollapseParagraph) { - parts[pi2] = (left + " " + right).replace(MULTIPLE_SPACES_REGEX, " ") - parts.removeAt(pi2 + 1) - } else { - pi2++ + private fun processIndividualParagraphs(paragraphs: List): List { + return paragraphs.map { p -> + if (p.trim().isEmpty()) return@map "" + + val lines = p.split('\n').map { it.trim() }.filter { it.isNotEmpty() } + if (lines.size <= 1) return@map p.trim() + + val sb = StringBuilder(lines[0]) + for (i in 1 until lines.size) { + val prevLine = lines[i - 1] + val curLine = lines[i] + val lastChar = prevLine.lastOrNull() ?: ' ' + + if (SENTENCE_ENDERS.contains(lastChar) || isHeading(curLine) || LIST_MARKER_REGEX.containsMatchIn(curLine)) { + sb.append("\n\n").append(curLine) + } else { + sb.append(" ").append(curLine) + } } + var result = sb.toString() + result = result.replace(MULTIPLE_SPACES_REGEX, " ") + result } + } - val collapsed = parts.joinToString("\n\n") - - val postParts = collapsed.split("\n\n").map { it.trim() }.toMutableList() - var idx = 0 - while (idx < postParts.size - 1) { - val left = postParts[idx] - val right = postParts[idx + 1] - if (left.isEmpty() || right.isEmpty()) { idx++; continue } - - if (normalized.contains(left + "\n\n" + right)) { idx++; continue } - - val leftLast = left.lastOrNull() - val rightFirst = right.firstOrNull() - val shouldMergeBecauseRightIsContinuation = (rightFirst != null && (rightFirst.isLowerCase() || rightFirst.isDigit())) && - (leftLast == null || !SENTENCE_ENDERS.contains(leftLast)) + private fun finalCollapse(processed: List, original: String): String { + val joined = processed.joinToString("\n\n").replace(THREE_PLUS_NEWLINES_REGEX, "\n\n") + val parts = joined.split("\n\n").map { it.trim() }.filter { it.isNotEmpty() }.toMutableList() + + collapseContinuationParagraphs(parts, original) + + var result = parts.joinToString("\n\n") + result = result.replace(SINGLE_NEWLINE_REGEX, " ") + result = result.replace(TWO_PLUS_SPACES_REGEX, " ") + return result.trim() + } - if (shouldMergeBecauseRightIsContinuation) { - postParts[idx] = (left + " " + right).replace(MULTIPLE_SPACES_REGEX, " ") - postParts.removeAt(idx + 1) - } else { - idx++ - } + private fun collapseContinuationParagraphs(parts: MutableList, original: String): Unit { + var i = 0 + while (i < parts.size - 1) { + val left = parts[i] + val right = parts[i + 1] + if (original.contains(left + "\n\n" + right)) { i++; continue } + + val leftLast = left.lastOrNull() ?: ' ' + val rightFirst = right.firstOrNull() ?: ' ' + + val shouldCollapse = !SENTENCE_ENDERS.contains(leftLast) && + (rightFirst.isLowerCase() || rightFirst.isDigit()) + + if (shouldCollapse) { + parts[i] = left + " " + right + parts[i] = parts[i].replace(MULTIPLE_SPACES_REGEX, " ") + parts.removeAt(i + 1) + } else i++ } - - var finallyCollapsed = postParts.joinToString("\n\n") - val collapsedSingleNewlines = finallyCollapsed.replace(SINGLE_NEWLINE_REGEX, " ") - return collapsedSingleNewlines.replace(TWO_PLUS_SPACES_REGEX, " ").trim() } - /** - * Debug helper used in tests to inspect how paragraphs are split. - */ /** * Clean a chapter title by removing junk and the novel name. */ fun cleanChapterTitle(fullTitle: String?, novelName: String): String { - if (fullTitle == null || fullTitle.isBlank()) return "" - var cleaned: String = fullTitle - val junkPatterns = listOf( - Regex("""(?i)^read\s+"""), - Regex("""(?i)\s+free\s+online.*$"""), - Regex("""(?i)\s+online\s+free.*$"""), - Regex("""(?i)\s*\|\s*.*$"""), - Regex("""(?i)\s+at\s+.*$"""), - Regex("""(?i)[\s–—\-:]*(MangaBat|NovelFire|MangaPark|MangaKakalot).*$"""), - Regex("""(?i)[\s–—\-:]*Scan.*$""") - ) - for (pattern in junkPatterns) { - cleaned = cleaned.replace(pattern, "") - } - if (novelName.isNotBlank()) { - if (cleaned.contains(novelName, ignoreCase = true)) { - cleaned = cleaned.replace(novelName, "", ignoreCase = true) - } + if (fullTitle.isNullOrBlank()) return "" + var cleaned = removeCommonJunk(fullTitle) + + if (novelName.isNotBlank() && cleaned.contains(novelName, ignoreCase = true)) { + cleaned = cleaned.replace(novelName, "", ignoreCase = true) } - cleaned = cleaned.replace(Regex("""^[\s–—\-:\|]+"""), "") - .replace(Regex("""[\s–—\-:\|]+$"""), "") - .trim() + + cleaned = cleanSeparators(cleaned) + if (cleaned.length > 40 || cleaned.contains("Chapter", ignoreCase = true) || cleaned.contains("Ch.", ignoreCase = true)) { - val extractedLabel = extractChapterLabel(cleaned) - if (extractedLabel != null) { - val subTitleRegex = Regex("""(?i)(?:chapter|ch|ch\.)\s*\d+[\s:\-—–\|]+(.+)""") - val match = subTitleRegex.find(cleaned) - val subTitle = match?.groupValues?.get(1)?.trim() - return if (!subTitle.isNullOrBlank() && subTitle.length > 2) { - "$extractedLabel: $subTitle" - } else { - extractedLabel - } - } - } - if (cleaned.isBlank() || (novelName.isNotBlank() && fullTitle.equals(novelName, ignoreCase = true))) { - return "" + val label = extractChapterLabel(cleaned) + if (label != null) { + val subTitleRegex = Regex("(?i)(?:chapter|ch|ch\\.)\\s*\\d+[\\s:\\-—–|]+(.+)") + val subTitle = subTitleRegex.find(cleaned)?.groupValues?.get(1)?.trim() + return if (!subTitle.isNullOrBlank() && subTitle.length > 2) (label + ": " + subTitle) else label + } } - return cleaned + + return if (cleaned.isBlank() || (novelName.isNotBlank() && fullTitle.equals(novelName, ignoreCase = true))) "" else cleaned } /** - * Guess if the content should be in paged mode based on image vs text count. + * Guess if the content should be in paged mode. */ fun guessIsPaged(content: io.aatricks.novelscraper.data.model.ChapterContent): Boolean { val imageCount = content.getImageCount() val textCount = content.getTextCount() if (textCount > imageCount * 2) return false - if (imageCount > 0) { - if (imageCount in 5..60 && textCount < 10) return true - if (imageCount > 60) return false - } - return false + return imageCount in 5..60 && textCount < 10 } } \ No newline at end of file diff --git a/app/src/test/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModelTest.kt b/app/src/test/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModelTest.kt new file mode 100644 index 0000000..d14a41a --- /dev/null +++ b/app/src/test/java/io/aatricks/novelscraper/ui/viewmodel/ReaderViewModelTest.kt @@ -0,0 +1,185 @@ +package io.aatricks.novelscraper.ui.viewmodel + +import io.aatricks.novelscraper.data.local.PreferencesManager +import io.aatricks.novelscraper.data.model.* +import io.aatricks.novelscraper.data.repository.ContentRepository +import io.aatricks.novelscraper.data.repository.ExploreRepository +import io.aatricks.novelscraper.data.repository.LibraryRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.* +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.Assert.* +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.* + +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Mock lateinit var contentRepository: ContentRepository + @Mock lateinit var libraryRepository: LibraryRepository + @Mock lateinit var exploreRepository: ExploreRepository + @Mock lateinit var preferencesManager: PreferencesManager + + private lateinit var viewModel: ReaderViewModel + + @Before + fun setup() { + MockitoAnnotations.openMocks(this) + Dispatchers.setMain(testDispatcher) + + whenever(preferencesManager.fontSize).thenReturn(18f) + whenever(preferencesManager.lineHeight).thenReturn(1.5f) + whenever(preferencesManager.fontFamily).thenReturn("Default") + whenever(preferencesManager.readerTheme).thenReturn(ReaderTheme.DARK.name) + whenever(preferencesManager.margins).thenReturn(16) + whenever(preferencesManager.paragraphSpacing).thenReturn(1.0f) + + runTest { + whenever(libraryRepository.getCurrentlyReading()).thenReturn(null) + whenever(libraryRepository.markAsCurrentlyReading(any())).thenReturn(true) + whenever(libraryRepository.updateProgress(any(), any(), any(), any(), any(), any(), any())).thenReturn(true) + whenever(libraryRepository.updateReadingMode(any(), any())).thenReturn(true) + } + + viewModel = ReaderViewModel( + contentRepository, + libraryRepository, + exploreRepository, + preferencesManager + ) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `initial state is correct`() = runTest { + val state = viewModel.uiState.value + assertEquals(18f, state.fontSize) + assertEquals(ReaderTheme.DARK, state.readerTheme) + } + + @Test + fun `loadContent saves current progress before loading new`() = runTest { + // Setup initial item + val initialItemId = "item-1" + val initialUrl = "https://example.com/1" + + // Mock success for first load + val result1 = ContentRepository.ContentResult.Success( + elements = emptyList(), + title = "Title 1", + url = initialUrl + ) + whenever(contentRepository.loadContent(initialUrl)).thenReturn(result1) + whenever(libraryRepository.getItemByUrl(initialUrl)).thenReturn( + LibraryItem(id = initialItemId, title = "Title 1", url = initialUrl) + ) + whenever(libraryRepository.getItemById(initialItemId)).thenReturn( + LibraryItem(id = initialItemId, title = "Title 1", url = initialUrl) + ) + + viewModel.loadContent(initialUrl) + advanceUntilIdle() + + assertEquals(initialUrl, viewModel.uiState.value.content?.url) + + // Now load a second item + val nextUrl = "https://example.com/2" + whenever(contentRepository.loadContent(nextUrl)).thenReturn( + ContentRepository.ContentResult.Success(emptyList(), "Title 2", nextUrl) + ) + + viewModel.loadContent(nextUrl) + advanceUntilIdle() + + // Verify updateProgress was called for the INITIAL item + verify(libraryRepository).updateProgress( + itemId = eq(initialItemId), + currentChapter = any(), + progress = any(), + currentChapterUrl = eq(initialUrl), + lastScrollProgress = any(), + lastReadIndex = any(), + lastReadOffset = any() + ) + } + + @Test + fun `updateScrollPosition saves progress after delay`() = runTest { + val itemId = "item-1" + val url = "https://example.com/1" + + // Set up current item + whenever(contentRepository.loadContent(url)).thenReturn( + ContentRepository.ContentResult.Success(listOf(ContentElement.Text("Test")), "Test", url) + ) + whenever(libraryRepository.getItemByUrl(url)).thenReturn( + LibraryItem(id = itemId, title = "Test", url = url) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn( + LibraryItem(id = itemId, title = "Test", url = url) + ) + + viewModel.loadContent(url) + advanceUntilIdle() + + viewModel.onUserInteraction() + + // Update scroll + viewModel.updateScrollPosition(50f, 100f, 10f, 5, 10) + + // Should NOT have saved yet (debounced) + verify(libraryRepository, never()).saveProgress(any(), any(), any(), any(), any(), any(), any()) + + // Advance time + advanceTimeBy(200) + runCurrent() + advanceUntilIdle() + + // Now it should have saved + verify(libraryRepository).saveProgress( + itemId = eq(itemId), + currentChapter = any(), + progress = any(), + currentChapterUrl = eq(url), + lastScrollProgress = any(), + lastReadIndex = eq(5), + lastReadOffset = eq(10) + ) + } + + @Test + fun `toggleReadingMode updates repository`() = runTest { + val itemId = "item-1" + val url = "https://example.com/1" + + whenever(contentRepository.loadContent(url)).thenReturn( + ContentRepository.ContentResult.Success(emptyList(), "Test", url) + ) + whenever(libraryRepository.getItemByUrl(url)).thenReturn( + LibraryItem(id = itemId, title = "Test", url = url) + ) + whenever(libraryRepository.getItemById(itemId)).thenReturn( + LibraryItem(id = itemId, title = "Test", url = url) + ) + + viewModel.loadContent(url) + advanceUntilIdle() + + val initialPagedMode = viewModel.uiState.value.isPagedMode + viewModel.toggleReadingMode() + advanceUntilIdle() + + assertNotEquals(initialPagedMode, viewModel.uiState.value.isPagedMode) + verify(libraryRepository).updateReadingMode(eq(itemId), any()) + } +}