Page previews for Exh/E-H and NH
- Still needs click image to open chapter
This commit is contained in:
parent
36461b52c0
commit
67e190bffd
@ -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()) }
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
@ -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 }
|
@ -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 <--
|
||||
}
|
||||
|
||||
|
@ -33,6 +33,7 @@ enum class MangaScreenItem {
|
||||
|
||||
// SY -->
|
||||
INFO_BUTTONS,
|
||||
CHAPTER_PREVIEW,
|
||||
|
||||
// SY <--
|
||||
CHAPTER_HEADER,
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
217
app/src/main/java/eu/kanade/tachiyomi/data/cache/PagePreviewCache.kt
vendored
Normal file
217
app/src/main/java/eu/kanade/tachiyomi/data/cache/PagePreviewCache.kt
vendored
Normal 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"
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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()!!
|
||||
|
@ -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
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 <--
|
||||
|
@ -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 <--
|
||||
|
36
app/src/main/java/exh/pagepreview/PagePreviewController.kt
Normal file
36
app/src/main/java/exh/pagepreview/PagePreviewController.kt
Normal 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"
|
||||
}
|
||||
}
|
103
app/src/main/java/exh/pagepreview/PagePreviewPresenter.kt
Normal file
103
app/src/main/java/exh/pagepreview/PagePreviewPresenter.kt
Normal 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()
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user