Page previews for Exh/E-H and NH

- Still needs click image to open chapter
This commit is contained in:
Jobobby04 2022-07-16 16:44:55 -04:00
parent 36461b52c0
commit 67e190bffd
22 changed files with 1446 additions and 9 deletions

View File

@ -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<MangaMetadataRepository> { MangaMetadataRepositoryImpl(get()) }
addFactory { GetFlatMetadataById(get()) }

View File

@ -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<PagePreviewSource>() ?: 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<PagePreview>,
val hasNextPage: Boolean,
val pageCount: Int?,
) : Result()
data class Error(val error: Throwable) : Result()
}
}

View File

@ -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<Int> = MutableStateFlow(-1)
@Transient
val progress = _progress.asStateFlow()
fun getPagePreviewInfo() = PagePreviewInfo(index, imageUrl, _progress)
}

View File

@ -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 }

View File

@ -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<Chapter>, 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<Chapter>, 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<Chapter>, bookmarked: Boolean) -> Unit,
@ -670,6 +686,9 @@ fun MangaScreenLargeImpl(
onMergeWithAnotherClicked = onMergeWithAnotherClicked,
)
}
if (state.pagePreviewsState !is PagePreviewState.Unused) {
PagePreviews(state.pagePreviewsState, onMorePreviewsClicked)
}
// SY <--
}

View File

@ -33,6 +33,7 @@ enum class MangaScreenItem {
// SY -->
INFO_BUTTONS,
CHAPTER_PREVIEW,
// SY <--
CHAPTER_HEADER,

View File

@ -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())
}
}

View File

@ -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)

View File

@ -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

View File

@ -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"
}
}

View File

@ -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<File>,
private val diskCacheKeyLazy: Lazy<String>,
private val sourceLazy: Lazy<PagePreviewSource?>,
private val callFactoryLazy: Lazy<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : 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<Call.Factory>,
private val diskCacheLazy: Lazy<DiskCache>,
) : Fetcher.Factory<PagePreview> {
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()
}
}

View File

@ -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<PagePreview> {
override fun key(data: PagePreview, options: Options): String {
return data.imageUrl
}
}

View File

@ -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<PagePreviewInfo>,
val hasNextPage: Boolean,
val pagePreviewPages: Int?,
)
@Serializable
data class PagePreviewInfo(
val index: Int,
val imageUrl: String,
@Transient
private val _progress: MutableStateFlow<Int> = 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
}
}
}

View File

@ -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<EHentaiSearchMetadata, Document>,
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<EHentaiThumbnailPreview> {
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()!!

View File

@ -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<NHentaiSearchMetadata, Response>,
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

View File

@ -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,

View File

@ -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<DomainChapter>,
val flatMetadata: FlatMetadata?,
val mergedData: MergedMangaData? = null,
val pagePreviewsState: PagePreviewState = PagePreviewState.Loading,
) {
constructor(pair: Pair<DomainManga, List<DomainChapter>>, 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<PagePreview>) : PagePreviewState()
data class Error(val error: Throwable) : PagePreviewState()
}
// SY <--

View File

@ -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 <--

View File

@ -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<PagePreviewPresenter> {
@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"
}
}

View File

@ -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<PagePreviewController>() {
private val _state = MutableStateFlow<PagePreviewState>(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<PagePreview>,
val hasNextPage: Boolean,
val pageCount: Int?,
val manga: Manga,
val source: Source,
) : PagePreviewState()
data class Error(val error: Throwable) : PagePreviewState()
}

View File

@ -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),
)
}
}
},
)
}

View File

@ -531,6 +531,12 @@
<string name="manga_id_is_null">Manga ID is null!</string>
<string name="loading_manga">Loading manga…</string>
<!-- Page previews -->
<string name="page_previews">Page previews</string>
<string name="more_previews">More previews</string>
<string name="pref_clear_page_preview_cache">Clear page preview cache</string>
<string name="page_preview_page_go_to">Go to</string>
<!-- Rating 0-10 (0, 0.5, 1, 1.5 and so fourth) -->
<string name="rating10">Masterpiece</string>
<string name="rating9">Amazing</string>