diff --git a/app/src/main/java/eu/kanade/domain/SYDomainModule.kt b/app/src/main/java/eu/kanade/domain/SYDomainModule.kt index a111f64b5..bbca37b21 100644 --- a/app/src/main/java/eu/kanade/domain/SYDomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/SYDomainModule.kt @@ -25,6 +25,7 @@ import eu.kanade.domain.manga.interactor.GetMergedManga import eu.kanade.domain.manga.interactor.GetMergedMangaById import eu.kanade.domain.manga.interactor.GetMergedMangaForDownloading import eu.kanade.domain.manga.interactor.GetMergedReferencesById +import eu.kanade.domain.manga.interactor.GetPagePreviews import eu.kanade.domain.manga.interactor.GetSearchMetadata import eu.kanade.domain.manga.interactor.GetSearchTags import eu.kanade.domain.manga.interactor.GetSearchTitles @@ -97,6 +98,7 @@ class SYDomainModule : InjektModule { addFactory { CreateSortTag(get(), get()) } addFactory { DeleteSortTag(get(), get()) } addFactory { ReorderSortTag(get(), get()) } + addFactory { GetPagePreviews(get()) } addSingletonFactory { MangaMetadataRepositoryImpl(get()) } addFactory { GetFlatMetadataById(get()) } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetPagePreviews.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetPagePreviews.kt new file mode 100644 index 000000000..f52c81277 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetPagePreviews.kt @@ -0,0 +1,47 @@ +package eu.kanade.domain.manga.interactor + +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.PagePreview +import eu.kanade.domain.manga.model.toMangaInfo +import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import eu.kanade.tachiyomi.source.PagePreviewSource +import eu.kanade.tachiyomi.source.Source +import exh.source.getMainSource + +class GetPagePreviews( + private val pagePreviewCache: PagePreviewCache, +) { + + suspend fun await(manga: Manga, source: Source, page: Int): Result { + @Suppress("NAME_SHADOWING") + val source = source.getMainSource() ?: return Result.Unused + return try { + val pagePreviews = try { + pagePreviewCache.getPageListFromCache(manga, page) + } catch (e: Exception) { + source.getPagePreviewList(manga.toMangaInfo(), page).also { + pagePreviewCache.putPageListToCache(manga, it) + } + } + Result.Success( + pagePreviews.pagePreviews.map { + PagePreview(it.index, it.imageUrl, source.id) + }, + pagePreviews.hasNextPage, + pagePreviews.pagePreviewPages, + ) + } catch (e: Exception) { + Result.Error(e) + } + } + + sealed class Result { + object Unused : Result() + data class Success( + val pagePreviews: List, + val hasNextPage: Boolean, + val pageCount: Int?, + ) : Result() + data class Error(val error: Throwable) : Result() + } +} diff --git a/app/src/main/java/eu/kanade/domain/manga/model/PagePreview.kt b/app/src/main/java/eu/kanade/domain/manga/model/PagePreview.kt new file mode 100644 index 000000000..0e224223e --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/manga/model/PagePreview.kt @@ -0,0 +1,22 @@ +package eu.kanade.domain.manga.model + +import eu.kanade.tachiyomi.source.PagePreviewInfo +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +@Serializable +data class PagePreview( + val index: Int, + val imageUrl: String, + val source: Long, +) { + @Transient + private val _progress: MutableStateFlow = MutableStateFlow(-1) + + @Transient + val progress = _progress.asStateFlow() + + fun getPagePreviewInfo() = PagePreviewInfo(index, imageUrl, _progress) +} diff --git a/app/src/main/java/eu/kanade/presentation/components/AroundLayout.kt b/app/src/main/java/eu/kanade/presentation/components/AroundLayout.kt new file mode 100644 index 000000000..cf5141263 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/components/AroundLayout.kt @@ -0,0 +1,63 @@ +package eu.kanade.presentation.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastMap +import androidx.compose.ui.util.fastMaxBy + +@Composable +fun AroundLayout( + modifier: Modifier = Modifier, + startLayout: @Composable () -> Unit, + endLayout: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + SubcomposeLayout(modifier) { constraints -> + val layoutWidth = constraints.maxWidth + + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + + val startLayoutPlaceables = subcompose(AroundLayoutContent.Start, startLayout).fastMap { + it.measure(looseConstraints) + } + + val startLayoutWidth = startLayoutPlaceables.fastMaxBy { it.width }?.width ?: 0 + + val endLayoutPlaceables = subcompose(AroundLayoutContent.End, endLayout).fastMap { + it.measure(looseConstraints) + } + + val endLayoutWidth = endLayoutPlaceables.fastMaxBy { it.width }?.width ?: 0 + + val bodyContentWidth = layoutWidth - startLayoutWidth + + val bodyContentPlaceables = subcompose(AroundLayoutContent.MainContent) { + Box(Modifier.padding(end = endLayoutWidth.toDp())) { + content() + } + }.fastMap { it.measure(looseConstraints.copy(maxWidth = bodyContentWidth)) } + + val height = (startLayoutPlaceables + endLayoutPlaceables + bodyContentPlaceables).maxOfOrNull { it.height } ?: 0 + + layout(constraints.maxWidth, height) { + // Placing to control drawing order to match default elevation of each placeable + + bodyContentPlaceables.fastForEach { + it.place(startLayoutWidth, 0) + } + startLayoutPlaceables.fastForEach { + it.place(0, 0) + } + // The bottom bar is always at the bottom of the layout + endLayoutPlaceables.fastForEach { + it.place(layoutWidth - endLayoutWidth, 0) + } + } + } +} + +private enum class AroundLayoutContent { Start, MainContent, End } diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 741f25087..03f11c80a 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -63,6 +63,7 @@ import eu.kanade.presentation.manga.components.MangaChapterListItem import eu.kanade.presentation.manga.components.MangaInfoBox import eu.kanade.presentation.manga.components.MangaInfoButtons import eu.kanade.presentation.manga.components.MangaSmallAppBar +import eu.kanade.presentation.manga.components.PagePreviews import eu.kanade.presentation.manga.components.SearchMetadataChips import eu.kanade.presentation.util.isScrolledToEnd import eu.kanade.presentation.util.isScrollingUp @@ -74,6 +75,7 @@ import eu.kanade.tachiyomi.source.getNameForMangaInfo import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.ui.manga.ChapterItem import eu.kanade.tachiyomi.ui.manga.MangaScreenState +import eu.kanade.tachiyomi.ui.manga.PagePreviewState import exh.source.MERGED_SOURCE_ID import exh.source.getMainSource @@ -108,6 +110,7 @@ fun MangaScreen( onMergedSettingsClicked: () -> Unit, onMergeClicked: () -> Unit, onMergeWithAnotherClicked: () -> Unit, + onMorePreviewsClicked: () -> Unit, // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, @@ -141,6 +144,7 @@ fun MangaScreen( onMergedSettingsClicked = onMergedSettingsClicked, onMergeClicked = onMergeClicked, onMergeWithAnotherClicked = onMergeWithAnotherClicked, + onMorePreviewsClicked = onMorePreviewsClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked, @@ -173,6 +177,7 @@ fun MangaScreen( onMergedSettingsClicked = onMergedSettingsClicked, onMergeClicked = onMergeClicked, onMergeWithAnotherClicked = onMergeWithAnotherClicked, + onMorePreviewsClicked = onMorePreviewsClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, onMultiMarkAsReadClicked = onMultiMarkAsReadClicked, onMarkPreviousAsReadClicked = onMarkPreviousAsReadClicked, @@ -211,6 +216,7 @@ private fun MangaScreenSmallImpl( onMergedSettingsClicked: () -> Unit, onMergeClicked: () -> Unit, onMergeWithAnotherClicked: () -> Unit, + onMorePreviewsClicked: () -> Unit, // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, @@ -432,6 +438,15 @@ private fun MangaScreenSmallImpl( ) } } + + if (state.pagePreviewsState !is PagePreviewState.Unused) { + item( + key = MangaScreenItem.CHAPTER_PREVIEW, + contentType = MangaScreenItem.CHAPTER_PREVIEW, + ) { + PagePreviews(state.pagePreviewsState, onMorePreviewsClicked) + } + } // SY <-- item( @@ -489,6 +504,7 @@ fun MangaScreenLargeImpl( onMergedSettingsClicked: () -> Unit, onMergeClicked: () -> Unit, onMergeWithAnotherClicked: () -> Unit, + onMorePreviewsClicked: () -> Unit, // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, @@ -670,6 +686,9 @@ fun MangaScreenLargeImpl( onMergeWithAnotherClicked = onMergeWithAnotherClicked, ) } + if (state.pagePreviewsState !is PagePreviewState.Unused) { + PagePreviews(state.pagePreviewsState, onMorePreviewsClicked) + } // SY <-- } diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt index bacef07bf..a1b897ad6 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreenConstants.kt @@ -33,6 +33,7 @@ enum class MangaScreenItem { // SY --> INFO_BUTTONS, + CHAPTER_PREVIEW, // SY <-- CHAPTER_HEADER, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/PagePreviews.kt b/app/src/main/java/eu/kanade/presentation/manga/components/PagePreviews.kt new file mode 100644 index 000000000..9fbff606d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/PagePreviews.kt @@ -0,0 +1,103 @@ +package eu.kanade.presentation.manga.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import coil.compose.SubcomposeAsyncImageContent +import eu.kanade.domain.manga.model.PagePreview +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.ui.manga.PagePreviewState +import exh.util.floor + +@Composable +fun PagePreviews(pagePreviewState: PagePreviewState, onMorePreviewsClicked: () -> Unit) { + when (pagePreviewState) { + PagePreviewState.Loading -> { + Box(modifier = Modifier.height(60.dp).fillMaxWidth(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + is PagePreviewState.Success -> { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val itemPerRowCount = (maxWidth / 120.dp).floor() + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + pagePreviewState.pagePreviews.take(4 * itemPerRowCount).chunked(itemPerRowCount).forEach { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + it.forEach { page -> + PagePreview( + modifier = Modifier.weight(1F), + page = page, + ) + } + } + } + TextButton(onClick = onMorePreviewsClicked) { + Text(stringResource(R.string.more_previews)) + } + } + } + } + else -> {} + } +} + +@Composable +fun PagePreview( + modifier: Modifier, + page: PagePreview, +) { + Column( + modifier + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + SubcomposeAsyncImage( + model = page, + contentDescription = null, + loading = { + val progress by page.progress.collectAsState() + if (progress != -1) { + CircularProgressIndicator(progress / 0.01F) + } else { + CircularProgressIndicator() + } + }, + success = { + SubcomposeAsyncImageContent( + contentDescription = null, + modifier = Modifier + .width(120.dp) + .heightIn(max = 200.dp), + contentScale = ContentScale.FillWidth, + ) + }, + ) + Text(page.index.toString()) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 597147aa3..fb28a1d4c 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -44,6 +44,8 @@ import eu.kanade.tachiyomi.data.coil.DomainMangaKeyer import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer import eu.kanade.tachiyomi.data.coil.MangaKeyer +import eu.kanade.tachiyomi.data.coil.PagePreviewFetcher +import eu.kanade.tachiyomi.data.coil.PagePreviewKeyer import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferenceValues @@ -178,6 +180,10 @@ class App : Application(), DefaultLifecycleObserver, ImageLoaderFactory { add(MangaKeyer()) add(DomainMangaKeyer()) add(MangaCoverKeyer()) + // SY --> + add(PagePreviewKeyer()) + add(PagePreviewFetcher.Factory(lazy(callFactoryInit), lazy(diskCacheInit))) + // SY <-- } callFactory(callFactoryInit) diskCache(diskCacheInit) diff --git a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt index 67fbf8bab..47e46382f 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/AppModule.kt @@ -18,6 +18,7 @@ import eu.kanade.data.listOfStringsAdapter import eu.kanade.data.listOfStringsAndAdapter import eu.kanade.tachiyomi.data.cache.ChapterCache import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.data.cache.PagePreviewCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.CustomMangaManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper @@ -116,6 +117,8 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { CustomMangaManager(app) } addSingletonFactory { EHentaiUpdateHelper(app) } + + addSingletonFactory { PagePreviewCache(app) } // SY <-- // Asynchronously init expensive components for a faster cold start diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/PagePreviewCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/PagePreviewCache.kt new file mode 100644 index 000000000..eb3c5b1bf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/PagePreviewCache.kt @@ -0,0 +1,217 @@ +package eu.kanade.tachiyomi.data.cache + +import android.content.Context +import android.text.format.Formatter +import com.jakewharton.disklrucache.DiskLruCache +import eu.kanade.domain.manga.model.Manga +import eu.kanade.tachiyomi.source.PagePreviewPage +import eu.kanade.tachiyomi.util.storage.DiskUtil +import eu.kanade.tachiyomi.util.storage.saveTo +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.Response +import okio.buffer +import okio.sink +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.io.IOException + +/** + * Class used to create page preview cache + * For each page in a page preview list a file is created + * For each page preview page a Json list is created and converted to a file. + * The files are in format *md5key*.0 + * + * @param context the application context. + * @constructor creates an instance of the page preview cache. + */ +class PagePreviewCache(private val context: Context) { + + companion object { + /** Name of cache directory. */ + const val PARAMETER_CACHE_DIRECTORY = "page_preview_disk_cache" + + /** Application cache version. */ + const val PARAMETER_APP_VERSION = 1 + + /** The number of values per cache entry. Must be positive. */ + const val PARAMETER_VALUE_COUNT = 1 + } + + /** Google Json class used for parsing JSON files. */ + private val json: Json by injectLazy() + + /** Cache class used for cache management. */ + private var diskCache = setupDiskCache(75) + + /** + * Returns directory of cache. + */ + private val cacheDir: File + get() = diskCache.directory + + /** + * Returns real size of directory. + */ + private val realSize: Long + get() = DiskUtil.getDirectorySize(cacheDir) + + /** + * Returns real size of directory in human readable format. + */ + val readableSize: String + get() = Formatter.formatFileSize(context, realSize) + + // --> EH + // Cache size is in MB + private fun setupDiskCache(cacheSize: Long): DiskLruCache { + return DiskLruCache.open( + File(context.cacheDir, PARAMETER_CACHE_DIRECTORY), + PARAMETER_APP_VERSION, + PARAMETER_VALUE_COUNT, + cacheSize * 1024 * 1024, + ) + } + // <-- EH + + /** + * Get page list from cache. + * + * @param manga the manga. + * @return the list of pages. + */ + fun getPageListFromCache(manga: Manga, page: Int): PagePreviewPage { + // Get the key for the manga. + val key = DiskUtil.hashKeyForDisk(getKey(manga, page)) + + // Convert JSON string to list of objects. Throws an exception if snapshot is null + return diskCache.get(key).use { + json.decodeFromString(it.getString(0)) + } + } + + /** + * Add page list to disk cache. + * + * @param manga the manga. + * @param pages list of pages. + */ + fun putPageListToCache(manga: Manga, pages: PagePreviewPage) { + // Convert list of pages to json string. + val cachedValue = json.encodeToString(pages) + + // Initialize the editor (edits the values for an entry). + var editor: DiskLruCache.Editor? = null + + try { + // Get editor from md5 key. + val key = DiskUtil.hashKeyForDisk(getKey(manga, pages.page)) + editor = diskCache.edit(key) ?: return + + // Write page preview urls to cache. + editor.newOutputStream(0).sink().buffer().use { + it.write(cachedValue.toByteArray()) + it.flush() + } + + diskCache.flush() + editor.commit() + editor.abortUnlessCommitted() + } catch (e: Exception) { + // Ignore. + } finally { + editor?.abortUnlessCommitted() + } + } + + /** + * Returns true if page is in cache. + * + * @param imageUrl url of page. + * @return true if in cache otherwise false. + */ + fun isImageInCache(imageUrl: String): Boolean { + return try { + diskCache.get(DiskUtil.hashKeyForDisk(imageUrl)) != null + } catch (e: IOException) { + false + } + } + + /** + * Get page file from url. + * + * @param imageUrl url of page. + * @return path of page. + */ + fun getImageFile(imageUrl: String): File { + // Get file from md5 key. + val imageName = DiskUtil.hashKeyForDisk(imageUrl) + ".0" + return File(diskCache.directory, imageName) + } + + /** + * Add page to cache. + * + * @param imageUrl url of page. + * @param response http response from page. + * @throws IOException page error. + */ + @Throws(IOException::class) + fun putImageToCache(imageUrl: String, response: Response) { + // Initialize editor (edits the values for an entry). + var editor: DiskLruCache.Editor? = null + + try { + // Get editor from md5 key. + val key = DiskUtil.hashKeyForDisk(imageUrl) + editor = diskCache.edit(key) ?: throw IOException("Unable to edit key") + + // Get OutputStream and write page with Okio. + response.body!!.source().saveTo(editor.newOutputStream(0)) + + diskCache.flush() + editor.commit() + } finally { + response.body?.close() + editor?.abortUnlessCommitted() + } + } + + fun clear(): Int { + var deletedFiles = 0 + cacheDir.listFiles()?.forEach { + if (removeFileFromCache(it.name)) { + deletedFiles++ + } + } + return deletedFiles + } + + /** + * Remove file from cache. + * + * @param file name of file "md5.0". + * @return status of deletion for the file. + */ + private fun removeFileFromCache(file: String): Boolean { + // Make sure we don't delete the journal file (keeps track of cache). + if (file == "journal" || file.startsWith("journal.")) { + return false + } + + return try { + // Remove the extension from the file to get the key of the cache + val key = file.substringBeforeLast(".") + // Remove file from cache. + diskCache.remove(key) + } catch (e: Exception) { + false + } + } + + private fun getKey(manga: Manga, page: Int): String { + return "${manga.id}_$page" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt new file mode 100644 index 000000000..7cad226a6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt @@ -0,0 +1,268 @@ +package eu.kanade.tachiyomi.data.coil + +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.disk.DiskCache +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.network.HttpException +import coil.request.Options +import coil.request.Parameters +import eu.kanade.domain.manga.model.PagePreview +import eu.kanade.tachiyomi.data.cache.PagePreviewCache +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.source.PagePreviewSource +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.system.logcat +import logcat.LogPriority +import okhttp3.CacheControl +import okhttp3.Call +import okhttp3.Request +import okhttp3.Response +import okhttp3.internal.closeQuietly +import okio.Path.Companion.toOkioPath +import okio.Source +import okio.buffer +import okio.sink +import uy.kohesive.injekt.injectLazy +import java.io.File +import java.net.HttpURLConnection + +/** + * A [Fetcher] that fetches page preview image for [PagePreview] object. + * + * Disk caching is handled by [PagePreviewCache], otherwise + * handled by Coil's [DiskCache]. + */ +class PagePreviewFetcher( + private val page: PagePreview, + private val options: Options, + private val pagePreviewFileLazy: Lazy, + private val diskCacheKeyLazy: Lazy, + private val sourceLazy: Lazy, + private val callFactoryLazy: Lazy, + private val diskCacheLazy: Lazy, +) : Fetcher { + + private val diskCacheKey: String + get() = diskCacheKeyLazy.value + + override suspend fun fetch(): FetchResult { + return httpLoader() + } + + private fun fileLoader(file: File): FetchResult { + return SourceResult( + source = ImageSource(file = file.toOkioPath(), diskCacheKey = diskCacheKey), + mimeType = "image/*", + dataSource = DataSource.DISK, + ) + } + + private suspend fun httpLoader(): FetchResult { + if (pagePreviewFileLazy.value.exists() && options.diskCachePolicy.readEnabled) { + return fileLoader(pagePreviewFileLazy.value) + } + var snapshot = readFromDiskCache() + try { + // Fetch from disk cache + if (snapshot != null) { + val snapshotPagePreviewCache = moveSnapshotToPagePreviewCache(snapshot, pagePreviewFileLazy.value) + if (snapshotPagePreviewCache != null) { + // Read from page preview cache + return fileLoader(snapshotPagePreviewCache) + } + + // Read from snapshot + return SourceResult( + source = snapshot.toImageSource(), + mimeType = "image/*", + dataSource = DataSource.DISK, + ) + } + + // Fetch from network + val response = executeNetworkRequest() + val responseBody = checkNotNull(response.body) { "Null response source" } + try { + // Read from page preview cache after page preview updated + val responsePagePreviewCache = writeResponseToPagePreviewCache(response, pagePreviewFileLazy.value) + if (responsePagePreviewCache != null) { + return fileLoader(responsePagePreviewCache) + } + + // Read from disk cache + snapshot = writeToDiskCache(snapshot, response) + if (snapshot != null) { + return SourceResult( + source = snapshot.toImageSource(), + mimeType = "image/*", + dataSource = DataSource.NETWORK, + ) + } + + // Read from response if cache is unused or unusable + return SourceResult( + source = ImageSource(source = responseBody.source(), context = options.context), + mimeType = "image/*", + dataSource = if (response.cacheResponse != null) DataSource.DISK else DataSource.NETWORK, + ) + } catch (e: Exception) { + responseBody.closeQuietly() + throw e + } + } catch (e: Exception) { + snapshot?.closeQuietly() + throw e + } + } + + private suspend fun executeNetworkRequest(): Response { + val response = sourceLazy.value?.fetchPreviewImage(page.getPagePreviewInfo(), getCacheControl()) ?: callFactoryLazy.value.newCall(newRequest()).await() + if (!response.isSuccessful && response.code != HttpURLConnection.HTTP_NOT_MODIFIED) { + response.body?.closeQuietly() + throw HttpException(response) + } + return response + } + + private fun getCacheControl(): CacheControl? { + val diskRead = options.diskCachePolicy.readEnabled + val networkRead = options.networkCachePolicy.readEnabled + return when { + !networkRead && diskRead -> { + CacheControl.FORCE_CACHE + } + networkRead && !diskRead -> if (options.diskCachePolicy.writeEnabled) { + CacheControl.FORCE_NETWORK + } else { + CACHE_CONTROL_FORCE_NETWORK_NO_CACHE + } + !networkRead && !diskRead -> { + // This causes the request to fail with a 504 Unsatisfiable Request. + CACHE_CONTROL_NO_NETWORK_NO_CACHE + } + else -> null + } + } + + private fun newRequest(): Request { + val request = Request.Builder() + .url(page.imageUrl) + .headers((sourceLazy.value as? HttpSource)?.headers ?: options.headers) + // Support attaching custom data to the network request. + .tag(Parameters::class.java, options.parameters) + + val cacheControl = getCacheControl() + if (cacheControl != null) { + request.cacheControl(cacheControl) + } + + return request.build() + } + + private fun moveSnapshotToPagePreviewCache(snapshot: DiskCache.Snapshot, cacheFile: File): File? { + return try { + diskCacheLazy.value.run { + fileSystem.source(snapshot.data).use { input -> + writeSourceToPagePreviewCache(input, cacheFile) + } + remove(diskCacheKey) + } + cacheFile.takeIf { it.exists() } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to write snapshot data to page preview cache ${cacheFile.name}" } + null + } + } + + private fun writeResponseToPagePreviewCache(response: Response, cacheFile: File): File? { + if (!options.diskCachePolicy.writeEnabled) return null + return try { + response.peekBody(Long.MAX_VALUE).source().use { input -> + writeSourceToPagePreviewCache(input, cacheFile) + } + cacheFile.takeIf { it.exists() } + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) { "Failed to write response data to page preview cache ${cacheFile.name}" } + null + } + } + + private fun writeSourceToPagePreviewCache(input: Source, cacheFile: File) { + cacheFile.parentFile?.mkdirs() + cacheFile.delete() + try { + cacheFile.sink().buffer().use { output -> + output.writeAll(input) + } + } catch (e: Exception) { + cacheFile.delete() + throw e + } + } + + private fun readFromDiskCache(): DiskCache.Snapshot? { + return if (options.diskCachePolicy.readEnabled) diskCacheLazy.value[diskCacheKey] else null + } + + private fun writeToDiskCache( + snapshot: DiskCache.Snapshot?, + response: Response, + ): DiskCache.Snapshot? { + if (!options.diskCachePolicy.writeEnabled) { + snapshot?.closeQuietly() + return null + } + val editor = if (snapshot != null) { + snapshot.closeAndEdit() + } else { + diskCacheLazy.value.edit(diskCacheKey) + } ?: return null + try { + diskCacheLazy.value.fileSystem.write(editor.data) { + response.body!!.source().readAll(this) + } + return editor.commitAndGet() + } catch (e: Exception) { + try { + editor.abort() + } catch (ignored: Exception) { + } + throw e + } + } + + private fun DiskCache.Snapshot.toImageSource(): ImageSource { + return ImageSource(file = data, diskCacheKey = diskCacheKey, closeable = this) + } + + class Factory( + private val callFactoryLazy: Lazy, + private val diskCacheLazy: Lazy, + ) : Fetcher.Factory { + + private val pagePreviewCache: PagePreviewCache by injectLazy() + private val sourceManager: SourceManager by injectLazy() + + override fun create(data: PagePreview, options: Options, imageLoader: ImageLoader): Fetcher { + return PagePreviewFetcher( + page = data, + options = options, + pagePreviewFileLazy = lazy { pagePreviewCache.getImageFile(data.imageUrl) }, + diskCacheKeyLazy = lazy { PagePreviewKeyer().key(data, options) }, + sourceLazy = lazy { sourceManager.get(data.source) as? PagePreviewSource }, + callFactoryLazy = callFactoryLazy, + diskCacheLazy = diskCacheLazy, + ) + } + } + + companion object { + private val CACHE_CONTROL_FORCE_NETWORK_NO_CACHE = CacheControl.Builder().noCache().noStore().build() + private val CACHE_CONTROL_NO_NETWORK_NO_CACHE = CacheControl.Builder().noCache().onlyIfCached().build() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewKeyer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewKeyer.kt new file mode 100644 index 000000000..abfe04552 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewKeyer.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.coil + +import coil.key.Keyer +import coil.request.Options +import eu.kanade.domain.manga.model.PagePreview + +class PagePreviewKeyer : Keyer { + override fun key(data: PagePreview, options: Options): String { + return data.imageUrl + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/PagePreviewSource.kt b/app/src/main/java/eu/kanade/tachiyomi/source/PagePreviewSource.kt new file mode 100644 index 000000000..5eee5b17f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/PagePreviewSource.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.source + +import eu.kanade.tachiyomi.network.ProgressListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import okhttp3.CacheControl +import okhttp3.Response +import tachiyomi.source.model.MangaInfo + +interface PagePreviewSource : Source { + + suspend fun getPagePreviewList(manga: MangaInfo, page: Int): PagePreviewPage + + suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl? = null): Response +} + +@Serializable +data class PagePreviewPage( + val page: Int, + val pagePreviews: List, + val hasNextPage: Boolean, + val pagePreviewPages: Int?, +) + +@Serializable +data class PagePreviewInfo( + val index: Int, + val imageUrl: String, + @Transient + private val _progress: MutableStateFlow = MutableStateFlow(-1), +) : ProgressListener { + @Transient + val progress = _progress.asStateFlow() + + override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { + _progress.value = if (contentLength > 0) { + (100 * bytesRead / contentLength).toInt() + } else { + -1 + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt index 50e12dbb2..08d3d7dae 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.source.online.all import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.net.Uri import androidx.compose.runtime.Composable import androidx.core.net.toUri @@ -10,6 +12,10 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.asObservableSuccess import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.newCallWithProgress +import eu.kanade.tachiyomi.source.PagePreviewInfo +import eu.kanade.tachiyomi.source.PagePreviewPage +import eu.kanade.tachiyomi.source.PagePreviewSource import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage @@ -72,11 +78,16 @@ import kotlinx.serialization.json.put import okhttp3.CacheControl import okhttp3.CookieJar import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.nodes.TextNode @@ -84,6 +95,8 @@ import rx.Observable import tachiyomi.source.model.ChapterInfo import tachiyomi.source.model.MangaInfo import uy.kohesive.injekt.injectLazy +import java.io.ByteArrayOutputStream +import java.io.IOException import java.net.URLEncoder // TODO Consider gallery updating when doing tabbed browsing @@ -94,7 +107,8 @@ class EHentai( ) : HttpSource(), MetadataSource, UrlImportableSource, - NamespaceSource { + NamespaceSource, + PagePreviewSource { override val metaClass = EHentaiSearchMetadata::class private val domain: String @@ -498,7 +512,7 @@ class EHentai( override fun searchMangaParse(response: Response) = genericMangaParse(response) override fun latestUpdatesParse(response: Response) = genericMangaParse(response) - private fun exGet(url: String, page: Int? = null, additionalHeaders: Headers? = null, cache: Boolean = true): Request { + private fun exGet(url: String, page: Int? = null, additionalHeaders: Headers? = null, cacheControl: CacheControl? = null): Request { return GET( if (page != null) { addParam(url, "page", (page - 1).toString()) @@ -513,10 +527,10 @@ class EHentai( headers.build() } else headers, ).let { - if (cache) { + if (cacheControl == null) { it } else { - it.newBuilder().cacheControl(CacheControl.FORCE_NETWORK).build() + it.newBuilder().cacheControl(cacheControl).build() } } } @@ -736,7 +750,7 @@ class EHentai( exGet( favoriteUrl, page = page, - cache = false, + cacheControl = CacheControl.FORCE_NETWORK, ), ).awaitResponse() } @@ -820,7 +834,9 @@ class EHentai( .build() chain.proceed(newReq) - }.build() + } + .addInterceptor(ThumbnailPreviewInterceptor()) + .build() // Filters override fun getFilterList(): FilterList { @@ -1080,11 +1096,154 @@ class EHentai( EHentaiDescription(state, openMetadataViewer, search) } + override suspend fun getPagePreviewList( + manga: MangaInfo, + page: Int, + ): PagePreviewPage { + val doc = client.newCall( + exGet( + (baseUrl + manga.key) + .toHttpUrl() + .newBuilder() + .removeAllQueryParameters("nw") + .addQueryParameter("p", (page - 1).toString()) + .build() + .toString(), + ), + ).await().asJsoup() + val previews = if (doc.selectFirst("div#gdo4 .ths")!!.attr("onClick").contains("inline_set=ts_l")) { + doc.body() + .select("#gdt div a") + .map { + PagePreviewInfo(it.text().toInt(), imageUrl = it.select("img").attr("src")) + } + } else { + parseNormalPreviewSet(doc) + .map { preview -> + PagePreviewInfo(preview.index, imageUrl = preview.toUrl()) + } + } + + return PagePreviewPage( + page = page, + pagePreviews = previews, + hasNextPage = doc.select("table.ptt tbody tr td") + .last()!! + .hasClass("ptdd") + .not(), + pagePreviewPages = doc.select("table.ptt tbody tr td a").asReversed() + .firstNotNullOfOrNull { it.text().toIntOrNull() }, + ) + } + + override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response { + return client.newCallWithProgress(exGet(page.imageUrl, cacheControl = cacheControl), page).await() + } + + /** + * Parse normal previews with regular expressions + */ + private fun parseNormalPreviewSet(doc: Document): List { + return doc.body() + .select("#gdt div div") + .map { it.selectFirst("img")!!.attr("alt").toInt() to it.attr("style") } + .map { (index, style) -> + val styles = style.split(";").mapNotNull { it.trimOrNull() } + val width = styles.first { it.startsWith("width:") } + .removePrefix("width:") + .removeSuffix("px") + .toInt() + + val height = styles.first { it.startsWith("height:") } + .removePrefix("height:") + .removeSuffix("px") + .toInt() + + val background = styles.first { it.startsWith("background:") } + .removePrefix("background:") + .split(" ") + + val url = background.first { it.startsWith("url(") } + .removePrefix("url(") + .removeSuffix(")") + + val widthOffset = background.first { it.startsWith("-") } + .removePrefix("-") + .removeSuffix("px") + .toInt() + + EHentaiThumbnailPreview(url, width, height, widthOffset, index) + } + } + data class EHentaiThumbnailPreview( + val imageUrl: String, + val width: Int, + val height: Int, + val widthOffset: Int, + val index: Int, + ) { + fun toUrl(): String { + return BLANK_PREVIEW_THUMB.toHttpUrl().newBuilder() + .addQueryParameter("imageUrl", imageUrl) + .addQueryParameter("width", width.toString()) + .addQueryParameter("height", height.toString()) + .addQueryParameter("widthOffset", widthOffset.toString()) + .build() + .toString() + } + + companion object { + fun parseFromUrl(url: HttpUrl) = EHentaiThumbnailPreview( + imageUrl = url.queryParameter("imageUrl")!!, + width = url.queryParameter("width")!!.toInt(), + height = url.queryParameter("height")!!.toInt(), + widthOffset = url.queryParameter("widthOffset")!!.toInt(), + index = -1, + ) + } + } + + private class ThumbnailPreviewInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (request.url.host == THUMB_DOMAIN && request.url.pathSegments.contains(BLANK_THUMB)) { + val thumbnailPreview = EHentaiThumbnailPreview.parseFromUrl(request.url) + val response = chain.proceed(request.newBuilder().url(thumbnailPreview.imageUrl).build()) + if (response.isSuccessful) { + val body = ByteArrayOutputStream() + .use { + val bitmap = BitmapFactory.decodeStream(response.body!!.byteStream()) + ?: throw IOException("Null bitmap($thumbnailPreview)") + Bitmap.createBitmap( + bitmap, + thumbnailPreview.widthOffset, + 0, + thumbnailPreview.width.coerceAtMost(bitmap.width - thumbnailPreview.widthOffset), + thumbnailPreview.height.coerceAtMost(bitmap.height), + ).compress(Bitmap.CompressFormat.JPEG, 100, it) + it.toByteArray() + } + .toResponseBody("image/jpeg".toMediaType()) + + return response.newBuilder().body(body).build() + } else { + return response + } + } + + return chain.proceed(request) + } + } + companion object { private const val TR_SUFFIX = "TR" private const val REVERSE_PARAM = "TEH_REVERSE" private val PAGE_COUNT_REGEX = "[0-9]*".toRegex() private val RATING_REGEX = "([0-9]*)px".toRegex() + private const val THUMB_DOMAIN = "ehgt.org" + private const val BLANK_THUMB = "blank.gif" + private const val BLANK_PREVIEW_THUMB = "https://$THUMB_DOMAIN/g/$BLANK_THUMB" private const val EH_API_BASE = "https://api.e-hentai.org/api.php" private val JSON = "application/json; charset=utf-8".toMediaTypeOrNull()!! diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt index 2ca77f077..bd477d64e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt @@ -4,7 +4,12 @@ import android.content.Context import android.content.SharedPreferences import android.net.Uri import androidx.compose.runtime.Composable +import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.newCallWithProgress +import eu.kanade.tachiyomi.source.PagePreviewInfo +import eu.kanade.tachiyomi.source.PagePreviewPage +import eu.kanade.tachiyomi.source.PagePreviewSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.toSManga import eu.kanade.tachiyomi.source.online.HttpSource @@ -23,6 +28,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import okhttp3.CacheControl import okhttp3.Response import tachiyomi.source.model.MangaInfo @@ -30,7 +36,8 @@ class NHentai(delegate: HttpSource, val context: Context) : DelegatedHttpSource(delegate), MetadataSource, UrlImportableSource, - NamespaceSource { + NamespaceSource, + PagePreviewSource { override val metaClass = NHentaiSearchMetadata::class override val lang = delegate.lang @@ -174,6 +181,35 @@ class NHentai(delegate: HttpSource, val context: Context) : NHentaiDescription(state, openMetadataViewer) } + override suspend fun getPagePreviewList(manga: MangaInfo, page: Int): PagePreviewPage { + val metadata = fetchOrLoadMetadata(manga.id()) { + client.newCall(mangaDetailsRequest(manga.toSManga())).await() + } + return PagePreviewPage( + page, + metadata.pageImageTypes.mapIndexed { index, s -> + PagePreviewInfo(index + 1, imageUrl = thumbnailUrlFromType(metadata.mediaId!!, index + 1, s)!!) + }, + false, + 1, + ) + } + + private fun thumbnailUrlFromType(mediaId: String, page: Int, t: String) = NHentaiSearchMetadata.typeToExtension(t)?.let { + "https://t3.nhentai.net/galleries/$mediaId/${page}t.$it" + } + + override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response { + return client.newCallWithProgress( + if (cacheControl != null) { + GET(page.imageUrl, cache = cacheControl) + } else { + GET(page.imageUrl) + }, + page, + ).await() + } + companion object { const val otherId = 7309872737163460316L diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt index 2e4eb21bc..52ebb4302 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt @@ -74,6 +74,7 @@ import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.widget.materialdialogs.QuadStateTextView import eu.kanade.tachiyomi.widget.materialdialogs.await import exh.md.similar.MangaDexSimilarController +import exh.pagepreview.PagePreviewController import exh.recs.RecommendsController import exh.source.MERGED_SOURCE_ID import exh.source.getMainSource @@ -191,6 +192,7 @@ class MangaController : onMergedSettingsClicked = this::openMergedSettingsDialog, onMergeClicked = this::openSmartSearch, onMergeWithAnotherClicked = this::mergeWithAnother, + onMorePreviewsClicked = this::openMorePagePreviews, // SY <-- onMultiBookmarkClicked = presenter::bookmarkChapters, onMultiMarkAsReadClicked = presenter::markChaptersRead, @@ -338,12 +340,19 @@ class MangaController : } } + // SY --> + private fun openMorePagePreviews() { + val manga = presenter.manga ?: return + router.pushController(PagePreviewController(manga.id)) + } + // SY <-- + // EXH --> - fun openSmartSearch() { + private fun openSmartSearch() { val manga = presenter.manga ?: return val smartSearchConfig = SourcesController.SmartSearchConfig(manga.title, manga.id) - router?.pushController( + router.pushController( SourcesController( bundleOf( SourcesController.SMART_SEARCH_CONFIG to smartSearchConfig, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt index 133d62142..b581d7a59 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt @@ -22,6 +22,7 @@ import eu.kanade.domain.manga.interactor.GetManga import eu.kanade.domain.manga.interactor.GetMangaWithChapters import eu.kanade.domain.manga.interactor.GetMergedMangaById import eu.kanade.domain.manga.interactor.GetMergedReferencesById +import eu.kanade.domain.manga.interactor.GetPagePreviews import eu.kanade.domain.manga.interactor.InsertManga import eu.kanade.domain.manga.interactor.InsertMergedReference import eu.kanade.domain.manga.interactor.SetMangaChapterFlags @@ -30,6 +31,7 @@ import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.domain.manga.interactor.UpdateMergedSettings import eu.kanade.domain.manga.model.MangaUpdate import eu.kanade.domain.manga.model.MergeMangaSettingsUpdate +import eu.kanade.domain.manga.model.PagePreview import eu.kanade.domain.manga.model.TriStateFilter import eu.kanade.domain.manga.model.isLocal import eu.kanade.domain.manga.model.toDbManga @@ -50,6 +52,7 @@ import eu.kanade.tachiyomi.data.track.EnhancedTrackService import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.PagePreviewSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.toSChapter @@ -139,6 +142,7 @@ class MangaPresenter( private val deleteMangaById: DeleteMangaById = Injekt.get(), private val deleteByMergeId: DeleteByMergeId = Injekt.get(), private val getFlatMetadata: GetFlatMetadataById = Injekt.get(), + private val getPagePreviews: GetPagePreviews = Injekt.get(), // SY <-- private val getDuplicateLibraryManga: GetDuplicateLibraryManga = Injekt.get(), private val setMangaChapterFlags: SetMangaChapterFlags = Injekt.get(), @@ -216,6 +220,7 @@ class MangaPresenter( val chapters: List, val flatMetadata: FlatMetadata?, val mergedData: MergedMangaData? = null, + val pagePreviewsState: PagePreviewState = PagePreviewState.Loading, ) { constructor(pair: Pair>, flatMetadata: FlatMetadata?) : this(pair.first, pair.second, flatMetadata) @@ -317,10 +322,18 @@ class MangaPresenter( isFromSource = isFromSource, trackingAvailable = trackManager.hasLoggedServices(), chapters = chapterItems, + // SY --> meta = raiseMetadata(flatMetadata, source), mergedData = mergedData, showRecommendationsInOverflow = preferences.recommendsInOverflow().get(), showMergeWithAnother = smartSearched, + pagePreviewsState = if (source.getMainSource() is PagePreviewSource) { + getPagePreviews(manga, source) + PagePreviewState.Loading + } else { + PagePreviewState.Unused + }, + // SY <-- ) } @@ -890,10 +903,28 @@ class MangaPresenter( } } + // SY --> private fun DomainChapter.toMergedDownloadedChapter() = copy( scanlator = scanlator?.substringAfter(": "), ) + private fun getPagePreviews(manga: DomainManga, source: Source) { + presenterScope.launchIO { + when (val result = getPagePreviews.await(manga, source, 1)) { + is GetPagePreviews.Result.Error -> updateSuccessState { + it.copy(pagePreviewsState = PagePreviewState.Error(result.error)) + } + is GetPagePreviews.Result.Success -> updateSuccessState { + it.copy(pagePreviewsState = PagePreviewState.Success(result.pagePreviews)) + } + GetPagePreviews.Result.Unused -> updateSuccessState { + it.copy(pagePreviewsState = PagePreviewState.Unused) + } + } + } + } + // SY <-- + /** * Requests an updated list of chapters from the source. */ @@ -1382,6 +1413,7 @@ sealed class MangaScreenState { val mergedData: MergedMangaData?, val showRecommendationsInOverflow: Boolean, val showMergeWithAnother: Boolean, + val pagePreviewsState: PagePreviewState, // SY <-- ) : MangaScreenState() { @@ -1446,3 +1478,12 @@ private val chapterDecimalFormat = DecimalFormat( DecimalFormatSymbols() .apply { decimalSeparator = '.' }, ) + +// SY --> +sealed class PagePreviewState { + object Unused : PagePreviewState() + object Loading : PagePreviewState() + data class Success(val pagePreviews: List) : PagePreviewState() + data class Error(val error: Throwable) : PagePreviewState() +} +// SY <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt index 709641343..f404580d2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsAdvancedController.kt @@ -20,6 +20,7 @@ import eu.kanade.domain.manga.interactor.GetAllManga import eu.kanade.domain.manga.repository.MangaRepository import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.cache.ChapterCache +import eu.kanade.tachiyomi.data.cache.PagePreviewCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Target @@ -86,6 +87,7 @@ class SettingsAdvancedController( private val trackManager: TrackManager by injectLazy() private val getAllManga: GetAllManga by injectLazy() private val getChapterByMangaId: GetChapterByMangaId by injectLazy() + private val pagePreviewCache: PagePreviewCache by injectLazy() @SuppressLint("BatteryLife") override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { @@ -160,6 +162,15 @@ class SettingsAdvancedController( onClick { clearChapterCache() } } + // SY --> + preference { + key = CLEAR_PREVIEW_CACHE_KEY + titleRes = R.string.pref_clear_page_preview_cache + summary = context.getString(R.string.used_cache, pagePreviewCache.readableSize) + + onClick { clearPagePreviewCache() } + } + // SY <-- switchPreference { key = Keys.autoClearChapterCache titleRes = R.string.pref_auto_clear_chapter_cache @@ -514,6 +525,23 @@ class SettingsAdvancedController( } } } + + private fun clearPagePreviewCache() { + val activity = activity ?: return + launchIO { + try { + val deletedFiles = pagePreviewCache.clear() + withUIContext { + activity.toast(resources?.getString(R.string.cache_deleted, deletedFiles)) + findPreference(CLEAR_PREVIEW_CACHE_KEY)?.summary = + resources?.getString(R.string.used_cache, pagePreviewCache.readableSize) + } + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) + withUIContext { activity.toast(R.string.cache_delete_error) } + } + } + } // SY <-- private fun clearChapterCache() { @@ -574,3 +602,7 @@ class SettingsAdvancedController( } private const val CLEAR_CACHE_KEY = "pref_clear_cache_key" + +// SY --> +private const val CLEAR_PREVIEW_CACHE_KEY = "pref_clear_preview_cache_key" +// SY <-- diff --git a/app/src/main/java/exh/pagepreview/PagePreviewController.kt b/app/src/main/java/exh/pagepreview/PagePreviewController.kt new file mode 100644 index 000000000..0e0baad2a --- /dev/null +++ b/app/src/main/java/exh/pagepreview/PagePreviewController.kt @@ -0,0 +1,36 @@ +package exh.pagepreview + +import android.os.Bundle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.core.os.bundleOf +import eu.kanade.tachiyomi.ui.base.controller.FullComposeController +import exh.pagepreview.components.PagePreviewScreen + +class PagePreviewController : FullComposeController { + + @Suppress("unused") + constructor(bundle: Bundle? = null) : super(bundle) + + constructor(mangaId: Long) : super( + bundleOf(MANGA_ID to mangaId), + ) + + override fun createPresenter() = PagePreviewPresenter(args.getLong(MANGA_ID, -1)) + + @Composable + override fun ComposeContent() { + PagePreviewScreen( + state = presenter.state.collectAsState().value, + pageDialogOpen = presenter.pageDialogOpen, + onPageSelected = presenter::moveToPage, + onOpenPageDialog = { presenter.pageDialogOpen = true }, + onDismissPageDialog = { presenter.pageDialogOpen = false }, + navigateUp = router::popCurrentController, + ) + } + + companion object { + const val MANGA_ID = "manga_id" + } +} diff --git a/app/src/main/java/exh/pagepreview/PagePreviewPresenter.kt b/app/src/main/java/exh/pagepreview/PagePreviewPresenter.kt new file mode 100644 index 000000000..74e057a3a --- /dev/null +++ b/app/src/main/java/exh/pagepreview/PagePreviewPresenter.kt @@ -0,0 +1,103 @@ +package exh.pagepreview + +import android.os.Bundle +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import eu.kanade.domain.manga.interactor.GetManga +import eu.kanade.domain.manga.interactor.GetPagePreviews +import eu.kanade.domain.manga.model.Manga +import eu.kanade.domain.manga.model.PagePreview +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceManager +import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter +import eu.kanade.tachiyomi.util.lang.launchIO +import eu.kanade.tachiyomi.util.system.logcat +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class PagePreviewPresenter( + private val mangaId: Long, + private val getPagePreviews: GetPagePreviews = Injekt.get(), + private val getManga: GetManga = Injekt.get(), + private val sourceManager: SourceManager = Injekt.get(), +) : BasePresenter() { + + private val _state = MutableStateFlow(PagePreviewState.Loading) + val state = _state.asStateFlow() + + private val page = MutableStateFlow(1) + + var pageDialogOpen by mutableStateOf(false) + + override fun onCreate(savedState: Bundle?) { + super.onCreate(savedState) + + presenterScope.launchIO { + val manga = getManga.await(mangaId)!! + val source = sourceManager.getOrStub(manga.source) + page + .onEach { page -> + when ( + val previews = getPagePreviews.await(manga, source, page) + ) { + is GetPagePreviews.Result.Error -> _state.update { + PagePreviewState.Error(previews.error) + } + is GetPagePreviews.Result.Success -> _state.update { + when (it) { + PagePreviewState.Loading, is PagePreviewState.Error -> { + PagePreviewState.Success( + page, + previews.pagePreviews, + previews.hasNextPage, + previews.pageCount, + manga, + source, + ) + } + is PagePreviewState.Success -> it.copy( + page = page, + pagePreviews = previews.pagePreviews, + hasNextPage = previews.hasNextPage, + pageCount = previews.pageCount, + ).also { logcat { page.toString() } } + } + } + GetPagePreviews.Result.Unused -> Unit + } + } + .catch { e -> + _state.update { + PagePreviewState.Error(e) + } + } + .collect() + } + } + + fun moveToPage(page: Int) { + this.page.value = page + } +} + +sealed class PagePreviewState { + object Loading : PagePreviewState() + + data class Success( + val page: Int, + val pagePreviews: List, + val hasNextPage: Boolean, + val pageCount: Int?, + val manga: Manga, + val source: Source, + ) : PagePreviewState() + + data class Error(val error: Throwable) : PagePreviewState() +} diff --git a/app/src/main/java/exh/pagepreview/components/PagePreviewScreen.kt b/app/src/main/java/exh/pagepreview/components/PagePreviewScreen.kt new file mode 100644 index 000000000..c48598e1c --- /dev/null +++ b/app/src/main/java/exh/pagepreview/components/PagePreviewScreen.kt @@ -0,0 +1,209 @@ +package exh.pagepreview.components + +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.UTurnRight +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SmallTopAppBar +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberTopAppBarScrollState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.components.AroundLayout +import eu.kanade.presentation.components.EmptyScreen +import eu.kanade.presentation.components.LoadingScreen +import eu.kanade.presentation.components.Scaffold +import eu.kanade.presentation.components.ScrollbarLazyColumn +import eu.kanade.presentation.manga.components.PagePreview +import eu.kanade.presentation.util.plus +import eu.kanade.presentation.util.topPaddingValues +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.logcat +import exh.pagepreview.PagePreviewState +import exh.util.floor +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +fun PagePreviewScreen( + state: PagePreviewState, + pageDialogOpen: Boolean, + onPageSelected: (Int) -> Unit, + onOpenPageDialog: () -> Unit, + onDismissPageDialog: () -> Unit, + navigateUp: () -> Unit, +) { + val topAppBarScrollState = rememberTopAppBarScrollState() + val topAppBarScrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarScrollState) + + Scaffold( + modifier = Modifier + .statusBarsPadding() + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + topBar = { + PagePreviewTopAppBar( + topAppBarScrollBehavior = topAppBarScrollBehavior, + navigateUp = navigateUp, + title = stringResource(id = R.string.page_previews), + onOpenPageDialog = onOpenPageDialog, + showOpenPageDialog = state is PagePreviewState.Success && + (state.pageCount != null && state.pageCount > 1 /* TODO support unknown pageCount || state.hasNextPage*/), + ) + }, + ) { paddingValues -> + when (state) { + is PagePreviewState.Error -> EmptyScreen(state.error.message.orEmpty()) + PagePreviewState.Loading -> LoadingScreen() + is PagePreviewState.Success -> { + BoxWithConstraints(Modifier.fillMaxSize()) { + val itemPerRowCount by derivedStateOf { (maxWidth / 120.dp).floor() } + val items by derivedStateOf { state.pagePreviews.chunked(itemPerRowCount) } + SideEffect { + logcat { (items.hashCode() to state.page).toString() } + } + ScrollbarLazyColumn( + modifier = Modifier, + contentPadding = paddingValues + topPaddingValues, + ) { + items.forEach { + item { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + it.forEach { page -> + PagePreview( + modifier = Modifier.weight(1F), + page = page, + ) + } + } + } + } + } + } + } + } + } + if (pageDialogOpen && state is PagePreviewState.Success) { + PagePreviewPageDialog( + currentPage = state.page, + pageCount = state.pageCount!!, + onDismissPageDialog = onDismissPageDialog, + onPageSelected = onPageSelected, + ) + } +} + +@Composable +fun PagePreviewPageDialog( + currentPage: Int, + pageCount: Int, + onDismissPageDialog: () -> Unit, + onPageSelected: (Int) -> Unit, +) { + var page by remember(currentPage) { + mutableStateOf(currentPage.toFloat()) + } + val scope = rememberCoroutineScope() + AlertDialog( + onDismissRequest = onDismissPageDialog, + confirmButton = { + TextButton(onClick = { + onPageSelected(page.roundToInt()) + onDismissPageDialog() + },) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismissPageDialog) { + Text(stringResource(android.R.string.cancel)) + } + }, + title = { + Text(stringResource(R.string.page_preview_page_go_to)) + }, + text = { + AroundLayout( + startLayout = { Text(text = page.roundToInt().toString()) }, + endLayout = { Text(text = pageCount.toString()) }, + ) { + Slider( + modifier = Modifier.fillMaxWidth(), + value = page, + onValueChange = { page = it }, + onValueChangeFinished = { + scope.launch { + val newPage = page + AnimationState( + newPage, + ).animateTo(newPage.roundToInt().toFloat()) { + page = value + } + } + }, + valueRange = 1F..pageCount.toFloat(), + ) + } + }, + ) +} + +@Composable +fun PagePreviewTopAppBar( + topAppBarScrollBehavior: TopAppBarScrollBehavior, + navigateUp: () -> Unit, + title: String, + onOpenPageDialog: () -> Unit, + showOpenPageDialog: Boolean, +) { + SmallTopAppBar( + navigationIcon = { + IconButton(onClick = navigateUp) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.abc_action_bar_up_description), + ) + } + }, + title = { + Text(text = title) + }, + scrollBehavior = topAppBarScrollBehavior, + actions = { + if (showOpenPageDialog) { + IconButton(onClick = onOpenPageDialog) { + Icon( + imageVector = Icons.Default.UTurnRight, + contentDescription = stringResource(R.string.page_preview_page_go_to), + ) + } + } + }, + ) +} diff --git a/app/src/main/res/values/strings_sy.xml b/app/src/main/res/values/strings_sy.xml index fd86b2ad4..567709371 100644 --- a/app/src/main/res/values/strings_sy.xml +++ b/app/src/main/res/values/strings_sy.xml @@ -531,6 +531,12 @@ Manga ID is null! Loading manga… + + Page previews + More previews + Clear page preview cache + Go to + Masterpiece Amazing