Share the reader's page chrome and page logic between viewers (#4193)

This commit is contained in:
AntsyLich
2026-10-26 23:36:46 +00:00
committed by GitHub
parent 9079ad8c9a
commit e588e04359
26 changed files with 1025 additions and 952 deletions
@@ -0,0 +1,65 @@
package eu.kanade.presentation.reader
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
/**
* A page that failed to load or decode: the message, Retry, and Open in WebView when [onOpenInWebView] is given.
*
* [onButtonPressChange] tells a viewer that reads every touch for its own taps when a button is held.
*/
@Composable
fun ReaderError(
message: String?,
onRetry: () -> Unit,
onOpenInWebView: (() -> Unit)?,
modifier: Modifier = Modifier,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
val buttonModifier = if (onButtonPressChange != null) Modifier.reportPress(onButtonPressChange) else Modifier
Column(
modifier = modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
Text(
text = message ?: stringResource(MR.strings.decode_image_error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry, modifier = buttonModifier) {
Text(stringResource(MR.strings.action_retry))
}
if (onOpenInWebView != null) {
Button(onClick = onOpenInWebView, modifier = buttonModifier) {
Text(stringResource(MR.strings.action_open_in_web_view))
}
}
}
}
internal fun Modifier.reportPress(onPressChange: (Boolean) -> Unit) = pointerInput(onPressChange) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
onPressChange(true)
waitForUpOrCancellation(pass = PointerEventPass.Initial)
onPressChange(false)
}
}
@@ -0,0 +1,11 @@
package eu.kanade.tachiyomi.ui.reader.viewer
/**
* How a viewer shows a wide page.
*/
interface DualPageSettings {
val dualPageSplit: Boolean
val dualPageInvert: Boolean
val dualPageRotateToFit: Boolean
val dualPageRotateToFitInvert: Boolean
}
@@ -0,0 +1,89 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import okio.BufferedSource
import tachiyomi.core.common.util.system.ImageUtil
/**
* The image of [page] as a pager shows it: a wide page rotated to fit or split in two, the half on the reading
* side first, so [isLeftToRight] tells which. [onPageSplit] is told of the [InsertPage] for the second half.
*/
fun DualPageSettings.processPagedImage(
page: ReaderPage,
imageSource: BufferedSource,
isLeftToRight: Boolean,
onPageSplit: (InsertPage) -> Unit,
): BufferedSource {
if (dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (!dualPageSplit) {
return imageSource
}
if (page is InsertPage) {
return splitInHalf(imageSource, page, isLeftToRight)
}
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) {
return imageSource
}
onPageSplit(InsertPage(page))
return splitInHalf(imageSource, page, isLeftToRight)
}
/**
* The image of a page as a strip shows it: a wide page rotated to fit, or split with its halves one above the other.
*/
fun DualPageSettings.processStripImage(imageSource: BufferedSource): BufferedSource {
if (dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) {
val upperSide = if (dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
return ImageUtil.splitAndMerge(imageSource, upperSide)
}
}
return imageSource
}
private fun DualPageSettings.rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
private fun DualPageSettings.splitInHalf(
imageSource: BufferedSource,
page: ReaderPage,
isLeftToRight: Boolean,
): BufferedSource {
var side = when {
isLeftToRight && page is InsertPage -> ImageUtil.Side.RIGHT
!isLeftToRight && page is InsertPage -> ImageUtil.Side.LEFT
isLeftToRight && page !is InsertPage -> ImageUtil.Side.LEFT
else -> ImageUtil.Side.RIGHT
}
if (dualPageInvert) {
side = when (side) {
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
}
}
return ImageUtil.splitInHalf(imageSource, side)
}
@@ -1,29 +0,0 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import com.google.android.material.R
import com.google.android.material.button.MaterialButton
import eu.kanade.tachiyomi.ui.reader.viewer.pager.PagerViewer
/**
* A button class to be used by child views of the pager viewer. All tap gestures are handled by
* the pager, but this class disables that behavior to allow clickable buttons.
*/
class ReaderButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.materialButtonStyle,
) : MaterialButton(context, attrs, defStyleAttr) {
var viewer: PagerViewer? = null
override fun onTouchEvent(event: MotionEvent): Boolean {
viewer?.pager?.setGestureDetectorEnabled(false)
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
viewer?.pager?.setGestureDetectorEnabled(true)
}
return super.onTouchEvent(event)
}
}
@@ -0,0 +1,78 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.reader.ReaderError
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
/**
* [ReaderError] for a [page] that failed with [error]: Retry asks its loader again, and an image from the web can
* be opened in a WebView with the cookies of the source [sourceId].
*/
@Composable
fun ReaderPageError(
page: ReaderPage,
error: Throwable?,
sourceId: Long?,
modifier: Modifier = Modifier,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
val context = LocalContext.current
val imageUrl = page.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
ReaderError(
message = with(context) { error?.formattedMessage },
onRetry = { page.chapter.pageLoader?.retryPage(page) },
onOpenInWebView = imageUrl?.let {
{ context.startActivity(WebViewActivity.newIntent(context, it, sourceId)) }
},
modifier = modifier,
onButtonPressChange = onButtonPressChange,
)
}
/**
* [ReaderPageError] for the view-based viewers, filling the view, in the theme of its reader-themed [context].
*/
class ReaderPageErrorView(context: Context) : AbstractComposeView(context) {
private var data by mutableStateOf<Data?>(null)
fun bind(
page: ReaderPage,
error: Throwable?,
sourceId: Long?,
onButtonPressChange: ((pressed: Boolean) -> Unit)? = null,
) {
data = Data(page, error, sourceId, onButtonPressChange)
}
@Composable
override fun Content() {
val data = data ?: return
ReaderThemedContent(context) {
ReaderPageError(
page = data.page,
error = data.error,
sourceId = data.sourceId,
modifier = Modifier.fillMaxSize(),
onButtonPressChange = data.onButtonPressChange,
)
}
}
private data class Data(
val page: ReaderPage,
val error: Throwable?,
val sourceId: Long?,
val onButtonPressChange: ((pressed: Boolean) -> Unit)?,
)
}
@@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import eu.kanade.presentation.theme.TachiyomiTheme
/**
* [content] in the app's theme under the reader's own night mode, from [readerThemedContext].
*
* Pages sit on the reader background, so a light app over a black reader would otherwise draw dark text on black.
* [TachiyomiTheme] reads that from the configuration, which the view-based viewers got by inflating their views
* from a reader-themed context.
*/
@Composable
fun ReaderThemedContent(readerThemedContext: Context, content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalContext provides readerThemedContext,
LocalConfiguration provides readerThemedContext.resources.configuration,
) {
TachiyomiTheme(content = content)
}
}
@@ -0,0 +1,86 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.reader.reportPress
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
@Composable
fun ReaderTransitionPage(
transition: ChapterTransition,
downloadState: TransitionDownloadState?,
onRetry: (ReaderChapter) -> Unit,
onButtonPressChange: (pressed: Boolean) -> Unit,
modifier: Modifier = Modifier,
statusPadding: PaddingValues = PaddingValues(),
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (downloadState != null) {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
ChapterTransition(
transition,
downloadState.currChapterDownloaded,
downloadState.goingToChapterDownloaded,
)
}
}
val toChapter = transition.to ?: return@Column
val state by toChapter.stateFlow.collectAsState()
when (val state = state) {
is ReaderChapter.State.Loading -> Column(
modifier = Modifier.padding(statusPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
text = stringResource(MR.strings.transition_pages_loading),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
}
is ReaderChapter.State.Error -> Column(
modifier = Modifier.padding(statusPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringResource(MR.strings.transition_pages_error, state.error.message ?: ""),
color = MaterialTheme.colorScheme.onBackground,
style = MaterialTheme.typography.bodyMedium,
)
Button(
onClick = { onRetry(toChapter) },
modifier = Modifier.reportPress(onButtonPressChange),
) {
Text(stringResource(MR.strings.action_retry))
}
}
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {}
}
}
}
@@ -2,91 +2,58 @@ package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context
import android.util.AttributeSet
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.AbstractComposeView
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
/**
* [ReaderTransitionPage] for the view-based viewers, in the theme of its reader-themed [context].
*/
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
AbstractComposeView(context, attrs) {
private var data: Data? by mutableStateOf(null)
init {
layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
}
fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?, source: Source?) {
data = if (manga != null && source != null) {
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
Data(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() ||
goingToChapter?.let {
downloadManager.isChapterDownloaded(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
sourceId = manga.source,
)
} ?: false,
isGoingToChapterOnDisk = goingToChapter?.let {
{
downloadManager.isChapterDownloadedOnDisk(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
source = source,
)
}
},
)
} else {
null
}
fun bind(
transition: ChapterTransition,
downloadState: TransitionDownloadState?,
onRetry: (ReaderChapter) -> Unit,
onButtonPressChange: (pressed: Boolean) -> Unit = {},
statusPadding: PaddingValues = PaddingValues(),
) {
data = Data(transition, downloadState, onRetry, onButtonPressChange, statusPadding)
}
@Composable
override fun Content() {
data?.let {
val goingToChapterDownloaded by produceState(it.goingToChapterDownloaded, it) {
it.isGoingToChapterOnDisk?.let { isOnDisk -> value = isOnDisk() }
}
TachiyomiTheme {
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
ChapterTransition(
transition = it.transition,
currChapterDownloaded = it.currChapterDownloaded,
goingToChapterDownloaded = goingToChapterDownloaded,
)
}
}
val data = data ?: return
LaunchedEffect(data.downloadState) { data.downloadState?.checkDisk() }
ReaderThemedContent(context) {
ReaderTransitionPage(
transition = data.transition,
downloadState = data.downloadState,
onRetry = data.onRetry,
onButtonPressChange = data.onButtonPressChange,
statusPadding = data.statusPadding,
)
}
}
private data class Data(
val transition: ChapterTransition,
val currChapterDownloaded: Boolean,
val goingToChapterDownloaded: Boolean,
val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
val downloadState: TransitionDownloadState?,
val onRetry: (ReaderChapter) -> Unit,
val onButtonPressChange: (pressed: Boolean) -> Unit,
val statusPadding: PaddingValues,
)
}
@@ -0,0 +1,56 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import tachiyomi.domain.manga.model.Manga
import tachiyomi.source.local.isLocal
class TransitionDownloadState(
val currChapterDownloaded: Boolean,
goingToChapterDownloaded: Boolean,
private val isGoingToChapterOnDisk: (suspend () -> Boolean)?,
) {
var goingToChapterDownloaded by mutableStateOf(goingToChapterDownloaded)
private set
suspend fun checkDisk() {
isGoingToChapterOnDisk?.let { goingToChapterDownloaded = it() }
}
}
fun DownloadManager.transitionDownloadState(
transition: ChapterTransition,
manga: Manga?,
source: Source?,
): TransitionDownloadState? {
if (manga == null || source == null) return null
val goingToChapter = transition.to?.chapter.takeUnless { manga.isLocal() }
return TransitionDownloadState(
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() ||
goingToChapter?.let {
isChapterDownloaded(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
sourceId = manga.source,
)
} ?: false,
isGoingToChapterOnDisk = goingToChapter?.let {
{
isChapterDownloadedOnDisk(
chapterName = it.name,
chapterScanlator = it.scanlator,
chapterUrl = it.url,
mangaTitle = manga.title,
source = source,
)
}
},
)
}
@@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.ui.reader.viewer
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
/**
* The items of a viewer for [chapters], top to bottom: the previous chapter's pages, its transition, [currentPages],
* then the next transition and chapter's pages, so moving between chapters is seamless. A transition is left out
* when its chapter is loaded and adjacent, unless [forceTransition].
*/
fun viewerItems(
chapters: ViewerChapters,
forceTransition: Boolean,
currentPages: List<ReaderPage>? = chapters.currChapter.pages,
): MutableList<Any> {
val items = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition
chapters.prevChapter?.pages?.let(items::addAll)
// Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
items.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
// Add current chapter.
currentPages?.let(items::addAll)
// Add next chapter transition and pages.
if (nextHasMissingChapters || forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
items.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
}
chapters.nextChapter?.pages?.let(items::addAll)
return items
}
@@ -0,0 +1,121 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.viewerItems
/**
* Items of a pager, where [ViewerChapters] updates are posted, inverted when the pager [isReversed] reads right to
* left. Wide pages split in two get an [InsertPage] for their second half.
*/
class PagerItems(private val isReversed: Boolean) {
/**
* List of currently set items.
*/
var items: List<Any> by mutableStateOf(emptyList())
private set
/**
* Holds preprocessed items so they don't get removed when changing chapter
*/
private var preprocessed: List<InsertPage> = emptyList()
var nextTransition: ChapterTransition.Next? = null
private set
var currentChapter: ReaderChapter? = null
private set
/**
* Updates the items with the given [chapters] while the pager is at [currentPosition], and returns where it must
* go next.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean, currentPosition: Int): Update {
var insertPageLastPage: InsertPage? = null
val currPages = chapters.currChapter.pages?.toMutableList()?.also { pages ->
val lastPage = pages.last()
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
// placed by their index, so only this chapter's belong here.
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
.sortedByDescending { it.index }
.forEach { page ->
if (lastPage.index == page.index) {
insertPageLastPage = page
}
pages.add(page.index + 1, page)
}
}
val newItems = viewerItems(chapters, forceTransition, currPages)
currentChapter = chapters.currChapter
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
val currentItem = items.getOrNull(currentPosition)
if (isReversed) {
newItems.reverse()
}
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
items = newItems
// The pager only follows the pages it has laid out, so before its first layout it would stay at the same
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
// chapter
val position = items.indexOf(currentItem).takeIf { it != -1 && it != currentPosition }
return Update(position, insertPageLastPage)
}
/**
* Adds [newPage], the second half of [currentPage], and returns whether the items changed.
*/
fun onPageSplit(currentPage: Any?, newPage: InsertPage): Boolean {
if (currentPage !is ReaderPage) return false
val currentIndex = items.indexOf(currentPage)
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
val chapterId = newPage.chapter.chapter.id
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
preprocessed = preprocessed + newPage
}
return false
}
val placeAtIndex = if (isReversed) currentIndex else currentIndex + 1
// It will enter a endless cycle of insert pages
if (isReversed && placeAtIndex - 1 >= 0 && items[placeAtIndex - 1] is InsertPage) {
return false
}
// Same here it will enter a endless cycle of insert pages
if (items[placeAtIndex] is InsertPage) {
return false
}
items = items.toMutableList().apply { add(placeAtIndex, newPage) }
return true
}
fun cleanupPageSplit() {
items = items.filterNot { it is InsertPage }
}
/**
* Where the pager goes after the items change: to [position] to stay on the same item, then to [insertPage] at
* the end of the chapter, which it would skip otherwise.
*/
class Update(val position: Int?, val insertPage: InsertPage?)
}
@@ -2,16 +2,12 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import androidx.core.view.isVisible
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageErrorView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.ui.reader.viewer.processPagedImage
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -22,11 +18,8 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
/**
* View of the ViewPager that contains a page of a chapter.
@@ -52,7 +45,7 @@ class PagerPageHolder(
/**
* Error layout to show when the image fails to load.
*/
private var errorLayout: ReaderErrorBinding? = null
private var errorLayout: ReaderPageErrorView? = null
private val scope = MainScope()
@@ -153,7 +146,9 @@ class PagerPageHolder(
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
// Splitting, merging and sampling the background decode the image, which is CPU work
val (source, isAnimated, background) = withContext(Dispatchers.Default) {
val source = process(item, buffer)
val source = viewer.simplePaged.processPagedImage(item, buffer, viewer is L2RPagerViewer) {
viewer.onPageSplit(item, it)
}
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
val background = if (!isAnimated && viewer.general.automaticBackground) {
ImageUtil.chooseBackground(context, source.peek().inputStream())
@@ -187,63 +182,6 @@ class PagerPageHolder(
}
}
private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource {
if (viewer.simplePaged.dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (!viewer.simplePaged.dualPageSplit) {
return imageSource
}
if (page is InsertPage) {
return splitInHalf(imageSource)
}
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (!isDoublePage) {
return imageSource
}
onPageSplit(page)
return splitInHalf(imageSource)
}
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (viewer.simplePaged.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
var side = when {
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT
viewer is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.LEFT
viewer !is L2RPagerViewer && page !is InsertPage -> ImageUtil.Side.RIGHT
else -> error("We should choose a side!")
}
if (viewer.simplePaged.dualPageInvert) {
side = when (side) {
ImageUtil.Side.RIGHT -> ImageUtil.Side.LEFT
ImageUtil.Side.LEFT -> ImageUtil.Side.RIGHT
}
}
return ImageUtil.splitInHalf(imageSource, side)
}
private fun onPageSplit(page: ReaderPage) {
val newPage = InsertPage(page)
viewer.onPageSplit(page, newPage)
}
/**
* Called when the page has an error.
*/
@@ -273,41 +211,24 @@ class PagerPageHolder(
viewer.activity.hideMenu()
}
private fun showErrorLayout(error: Throwable?): ReaderErrorBinding {
if (errorLayout == null) {
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), this, true)
errorLayout?.actionRetry?.viewer = viewer
errorLayout?.actionRetry?.setOnClickListener {
page.chapter.pageLoader?.retryPage(page)
}
private fun showErrorLayout(error: Throwable?) {
val errorLayout = errorLayout ?: ReaderPageErrorView(context).also {
addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
errorLayout = it
}
val imageUrl = page.imageUrl
errorLayout?.actionOpenInWebView?.isVisible = imageUrl != null
if (imageUrl != null) {
if (imageUrl.startsWith("http", true)) {
errorLayout?.actionOpenInWebView?.viewer = viewer
errorLayout?.actionOpenInWebView?.setOnClickListener {
val sourceId = viewer.activity.viewModel.manga?.source
val intent = WebViewActivity.newIntent(context, imageUrl, sourceId)
context.startActivity(intent)
}
}
}
errorLayout?.errorMessage?.text = with(context) { error?.formattedMessage }
?: context.stringResource(MR.strings.decode_image_error)
errorLayout?.root?.isVisible = true
return errorLayout!!
errorLayout.bind(
page = page,
error = error,
sourceId = viewer.activity.viewModel.manga?.source,
onButtonPressChange = { pressed -> viewer.pager.setGestureDetectorEnabled(!pressed) },
)
}
/**
* Removes the decode error layout from the holder, if found.
*/
private fun removeErrorLayout() {
errorLayout?.root?.isVisible = false
errorLayout?.let(::removeView)
errorLayout = null
}
}
@@ -0,0 +1,151 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import tachiyomi.core.common.util.system.logcat
/**
* The item a pager of [items] is on, shared by the view and Compose pagers. It tells [activity] of each new page,
* preloads the chapters around it, and gives [onPageHolderSelected] the page along with whether it was reached moving
* forward. Chapters set while the pager moves are held until it's idle and then given to [setChaptersNow].
*/
class PagerPageTracker(
private val activity: ReaderActivity,
private val items: PagerItems,
private val setChaptersNow: (ViewerChapters) -> Unit,
private val onPageHolderSelected: (page: ReaderPage, forward: Boolean) -> Unit,
) {
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
var currentPage: Any? = null
private set
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: ViewerChapters? = null
/**
* Whether the pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersNow(viewerChapters)
awaitingIdleViewerChapters = null
if (viewerChapters.currChapter.pages?.size == 1) {
items.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
}
}
/**
* Sets the given [chapters] now if the pager is idle, otherwise once it becomes idle.
*/
fun setChapters(chapters: ViewerChapters) {
if (isIdle) {
setChaptersNow(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
}
/**
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
*/
fun onPageChange(position: Int) {
val page = items.items.getOrNull(position)
if (page != null && currentPage != page) {
val allowPreload = checkAllowPreload(page as? ReaderPage)
val forward = when {
currentPage is ReaderPage && page is ReaderPage -> {
// if both pages have the same number, it's a split page with an InsertPage
if (page.number == (currentPage as ReaderPage).number) {
// the InsertPage is always the second in the reading direction
page is InsertPage
} else {
page.number > (currentPage as ReaderPage).number
}
}
currentPage is ChapterTransition.Prev && page is ReaderPage ->
false
else -> true
}
currentPage = page
when (page) {
is ReaderPage -> onReaderPageSelected(page, allowPreload, forward)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
// Allow preload for
// 1. Going to next chapter from chapter transition
// 2. Going between pages of same chapter
// 3. Next chapter page
return when (page.chapter) {
(currentPage as? ChapterTransition.Next)?.to -> true
(currentPage as? ReaderPage)?.chapter -> true
items.nextTransition?.to -> true
else -> false
}
}
/**
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, forward: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onReaderPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Notify holder of page change
onPageHolderSelected(page, forward)
// Skip preload on inserts it causes unwanted page jumping
if (page is InsertPage) {
return
}
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == items.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
items.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
/**
* Called when a [ChapterTransition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
}
@@ -3,25 +3,12 @@ package eu.kanade.tachiyomi.ui.reader.viewer.pager
import android.annotation.SuppressLint
import android.content.Context
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatTextView
import com.google.android.material.progressindicator.CircularProgressIndicator
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderTransitionView
import eu.kanade.tachiyomi.ui.reader.viewer.transitionDownloadState
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
/**
* View of the ViewPager that contains a chapter transition.
@@ -33,25 +20,12 @@ class PagerTransitionHolder(
val transition: ChapterTransition,
) : LinearLayout(readerThemedContext), ViewPagerAdapter.PositionableView {
private val scope = MainScope()
private var stateJob: Job? = null
/**
* Item that identifies this view. Needed by the adapter to not recreate views.
*/
override val item: Any
get() = transition
/**
* View container of the current status of the transition page. Child views will be added
* dynamically.
*/
private var pagesContainer = LinearLayout(context).apply {
layoutParams = LayoutParams(MATCH_PARENT, WRAP_CONTENT)
orientation = VERTICAL
gravity = Gravity.CENTER
}
init {
orientation = VERTICAL
gravity = Gravity.CENTER
@@ -60,92 +34,16 @@ class PagerTransitionHolder(
val transitionView = ReaderTransitionView(context)
addView(transitionView)
addView(pagesContainer)
transitionView.bind(
transition,
viewer.downloadManager,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
transition = transition,
downloadState = viewer.downloadManager.transitionDownloadState(
transition,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
),
onRetry = viewer.activity::requestPreloadChapter,
onButtonPressChange = { pressed -> viewer.pager.setGestureDetectorEnabled(!pressed) },
)
transition.to?.let(::observeStatus)
}
/**
* Called when this view is detached from the window. Unsubscribes any active subscription.
*/
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
stateJob?.cancel()
}
/**
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
* state, the pages container is cleaned up before setting the new state.
*/
private fun observeStatus(chapter: ReaderChapter) {
stateJob?.cancel()
stateJob = scope.launch {
chapter.stateFlow
.collectLatest { state ->
pagesContainer.removeAllViews()
when (state) {
is ReaderChapter.State.Loading -> setLoading()
is ReaderChapter.State.Error -> setError(state.error)
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {
// No additional view is added
}
}
}
}
}
/**
* Sets the loading state on the pages container.
*/
private fun setLoading() {
val progress = CircularProgressIndicator(context)
progress.isIndeterminate = true
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_loading)
}
pagesContainer.addView(progress)
pagesContainer.addView(textView)
}
/**
* Sets the error state on the pages container.
*/
private fun setError(error: Throwable) {
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_error, error.message ?: "")
}
val retryBtn = ReaderButton(context).apply {
viewer = this@PagerTransitionHolder.viewer
wrapContent()
text = context.stringResource(MR.strings.action_retry)
setOnClickListener {
val toChapter = transition.to
if (toChapter != null) {
this@PagerTransitionHolder.viewer.activity.requestPreloadChapter(toChapter)
}
}
}
pagesContainer.addView(textView)
pagesContainer.addView(retryBtn)
}
/**
* Extension method to set layout params to wrap content on this view.
*/
private fun View.wrapContent() {
layoutParams = ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)
}
}
@@ -82,50 +82,27 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
},
)
private val pagerItems = PagerItems(isReversed = this is R2LPagerViewer)
/**
* Adapter of the pager.
*/
private val adapter = PagerViewerAdapter(this)
private val adapter = PagerViewerAdapter(this, pagerItems)
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
/**
* Viewer chapters to set when the pager enters idle mode. Otherwise, if the view was settling
* or dragging, there'd be a noticeable and annoying jump.
*/
private var awaitingIdleViewerChapters: ViewerChapters? = null
/**
* Whether the view pager is currently in idle mode. It sets the awaiting chapters if setting
* this field to true.
*/
private var isIdle = true
set(value) {
field = value
if (value) {
awaitingIdleViewerChapters?.let { viewerChapters ->
setChaptersInternal(viewerChapters)
awaitingIdleViewerChapters = null
if (viewerChapters.currChapter.pages?.size == 1) {
adapter.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
}
}
private val tracker = PagerPageTracker(activity, pagerItems, ::setChaptersInternal) { page, forward ->
getPageHolder(page)?.onPageSelected(forward)
}
private val pagerListener = object : ViewPager.SimpleOnPageChangeListener() {
override fun onPageSelected(position: Int) {
if (!activity.isScrollingThroughPages) {
activity.hideMenu()
}
onPageChange(position)
tracker.onPageChange(position)
}
override fun onPageScrollStateChanged(state: Int) {
isIdle = state == ViewPager.SCROLL_STATE_IDLE
tracker.isIdle = state == ViewPager.SCROLL_STATE_IDLE
}
}
@@ -217,105 +194,12 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
.filterIsInstance(PagerPageHolder::class.java)
.firstOrNull { it.item == page }
/**
* Called when a new page (either a [ReaderPage] or [ChapterTransition]) is marked as active
*/
private fun onPageChange(position: Int) {
val page = adapter.items.getOrNull(position)
if (page != null && currentPage != page) {
val allowPreload = checkAllowPreload(page as? ReaderPage)
val forward = when {
currentPage is ReaderPage && page is ReaderPage -> {
// if both pages have the same number, it's a split page with an InsertPage
if (page.number == (currentPage as ReaderPage).number) {
// the InsertPage is always the second in the reading direction
page is InsertPage
} else {
page.number > (currentPage as ReaderPage).number
}
}
currentPage is ChapterTransition.Prev && page is ReaderPage ->
false
else -> true
}
currentPage = page
when (page) {
is ReaderPage -> onReaderPageSelected(page, allowPreload, forward)
is ChapterTransition -> onTransitionSelected(page)
}
}
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
// Allow preload for
// 1. Going to next chapter from chapter transition
// 2. Going between pages of same chapter
// 3. Next chapter page
return when (page.chapter) {
(currentPage as? ChapterTransition.Next)?.to -> true
(currentPage as? ReaderPage)?.chapter -> true
adapter.nextTransition?.to -> true
else -> false
}
}
/**
* Called when a [ReaderPage] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onReaderPageSelected(page: ReaderPage, allowPreload: Boolean, forward: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onReaderPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Notify holder of page change
getPageHolder(page)?.onPageSelected(forward)
// Skip preload on inserts it causes unwanted page jumping
if (page is InsertPage) {
return
}
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
adapter.nextTransition?.to?.let(activity::requestPreloadChapter)
}
}
/**
* Called when a [ChapterTransition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
} else if (transition is ChapterTransition.Next) {
// No more chapters, show menu because the user is probably going to close the reader
activity.showMenu()
}
}
/**
* Tells this viewer to set the given [chapters] as active. If the pager is currently idle,
* it sets the chapters immediately, otherwise they are saved and set when it becomes idle.
*/
override fun setChapters(chapters: ViewerChapters) {
if (isIdle) {
setChaptersInternal(chapters)
} else {
awaitingIdleViewerChapters = chapters
}
tracker.setChapters(chapters)
}
/**
@@ -339,7 +223,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.addOnPageChangeListener(pagerListener)
// Manually call onPageChange to update the UI
onPageChange(pager.currentItem)
tracker.onPageChange(pager.currentItem)
}
/**
@@ -352,7 +236,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
pager.setCurrentItem(position, true)
// manually call onPageChange since ViewPager listener is not triggered in this case
if (currentPosition == position) {
onPageChange(position)
tracker.onPageChange(position)
}
} else {
logcat { "Page $page not found in adapter" }
@@ -373,7 +257,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
*/
private fun moveRight() {
if (pager.currentItem != adapter.count - 1) {
val holder = (currentPage as? ReaderPage)?.let(::getPageHolder)
val holder = (tracker.currentPage as? ReaderPage)?.let(::getPageHolder)
if (holder != null && paged.navigateToPan && holder.canPanRight()) {
holder.panRight()
} else {
@@ -387,7 +271,7 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
*/
private fun moveLeft() {
if (pager.currentItem != 0) {
val holder = (currentPage as? ReaderPage)?.let(::getPageHolder)
val holder = (tracker.currentPage as? ReaderPage)?.let(::getPageHolder)
if (holder != null && paged.navigateToPan && holder.canPanLeft()) {
holder.panLeft()
} else {
@@ -4,10 +4,8 @@ import android.view.View
import android.view.ViewGroup
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
import eu.kanade.tachiyomi.widget.ViewPagerAdapter
import tachiyomi.core.common.util.system.logcat
@@ -15,23 +13,16 @@ import tachiyomi.core.common.util.system.logcat
/**
* Pager adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
class PagerViewerAdapter(
private val viewer: PagerViewer,
private val pagerItems: PagerItems,
) : ViewPagerAdapter() {
/**
* List of currently set items.
*/
var items: MutableList<Any> = mutableListOf()
private set
/**
* Holds preprocessed items so they don't get removed when changing chapter
*/
private var preprocessed: List<InsertPage> = emptyList()
var nextTransition: ChapterTransition.Next? = null
private set
var currentChapter: ReaderChapter? = null
val items: List<Any>
get() = pagerItems.items
/**
* Context that has been wrapped to use the correct theme values based on the
@@ -45,85 +36,13 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
* has R2L direction.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
val newItems = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition
chapters.prevChapter?.pages?.let(newItems::addAll)
// Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
var insertPageLastPage: InsertPage? = null
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
val pages = currPages.toMutableList()
val lastPage = pages.last()
// Insert preprocessed pages into current page list. Pages of the adjacent chapters are split too, and
// placed by their index, so only this chapter's belong here.
preprocessed.filter { it.chapter.chapter.id == chapters.currChapter.chapter.id }
.sortedByDescending { it.index }
.forEach { page ->
if (lastPage.index == page.index) {
insertPageLastPage = page
}
pages.add(page.index + 1, page)
}
newItems.addAll(pages)
}
currentChapter = chapters.currChapter
// Add next chapter transition and pages.
nextTransition = ChapterTransition.Next(chapters.currChapter, chapters.nextChapter)
.also {
if (
nextHasMissingChapters ||
forceTransition ||
chapters.nextChapter?.state !is ReaderChapter.State.Loaded
) {
newItems.add(it)
}
}
chapters.nextChapter?.pages?.let(newItems::addAll)
val currentItem = items.getOrNull(viewer.pager.currentItem)
// Resets double-page splits, else insert pages get misplaced
items.filterIsInstance<InsertPage>().also { items.removeAll(it) }
if (viewer is R2LPagerViewer) {
newItems.reverse()
}
val adjacentChapterIds = setOfNotNull(chapters.prevChapter?.chapter?.id, chapters.nextChapter?.chapter?.id)
preprocessed = preprocessed.filter { it.chapter.chapter.id in adjacentChapterIds }
items = newItems
val update = pagerItems.setChapters(chapters, forceTransition, viewer.pager.currentItem)
notifyDataSetChanged()
// The pager only follows the pages it has created, so before its first layout it would stay at the same
// position while pages are added in front, e.g. the next chapter's in right to left, and land in another
// chapter
val currentPosition = items.indexOf(currentItem)
if (currentPosition != -1 && currentPosition != viewer.pager.currentItem) {
viewer.pager.setCurrentItem(currentPosition, false)
}
update.position?.let { viewer.pager.setCurrentItem(it, false) }
// Will skip insert page otherwise
if (insertPageLastPage != null) {
viewer.moveToPage(insertPageLastPage)
}
update.insertPage?.let(viewer::moveToPage)
}
/**
@@ -160,44 +79,13 @@ class PagerViewerAdapter(private val viewer: PagerViewer) : ViewPagerAdapter() {
}
fun onPageSplit(currentPage: Any?, newPage: InsertPage) {
if (currentPage !is ReaderPage) return
val currentIndex = items.indexOf(currentPage)
// Put aside pages of an adjacent chapter until it becomes the current one, so they are not lost on the change
if (currentPage.chapter.chapter.id != currentChapter?.chapter?.id) {
val chapterId = newPage.chapter.chapter.id
if (preprocessed.none { it.chapter.chapter.id == chapterId && it.index == newPage.index }) {
preprocessed = preprocessed + newPage
}
return
if (pagerItems.onPageSplit(currentPage, newPage)) {
notifyDataSetChanged()
}
val placeAtIndex = when (viewer) {
is L2RPagerViewer,
is VerticalPagerViewer,
-> currentIndex + 1
else -> currentIndex
}
// It will enter a endless cycle of insert pages
if (viewer is R2LPagerViewer && placeAtIndex - 1 >= 0 && items[placeAtIndex - 1] is InsertPage) {
return
}
// Same here it will enter a endless cycle of insert pages
if (items[placeAtIndex] is InsertPage) {
return
}
items.add(placeAtIndex, newPage)
notifyDataSetChanged()
}
fun cleanupPageSplit() {
val insertPages = items.filterIsInstance<InsertPage>()
items.removeAll(insertPages)
pagerItems.cleanupPageSplit()
notifyDataSetChanged()
}
@@ -1,11 +1,13 @@
package eu.kanade.tachiyomi.ui.reader.viewer.pager
import eu.kanade.tachiyomi.ui.reader.viewer.DualPageSettings
/**
* Settings only the pager viewers use, all of them part of what the pages are made with.
*/
data class SimplePagedSettings(
val dualPageSplit: Boolean,
val dualPageInvert: Boolean,
val dualPageRotateToFit: Boolean,
val dualPageRotateToFitInvert: Boolean,
)
override val dualPageSplit: Boolean,
override val dualPageInvert: Boolean,
override val dualPageRotateToFit: Boolean,
override val dualPageRotateToFitInvert: Boolean,
) : DualPageSettings
@@ -1,19 +1,16 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Button
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -22,24 +19,21 @@ import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import ca.mpreg.webgpuviewer.viewer.ComposePageSize
import ca.mpreg.webgpuviewer.viewer.ImagePage
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.presentation.reader.ReaderError
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderThemedContent
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CombinedCircularProgressIndicator
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.source.local.isLocal
/** The side padding `PagerTransitionHolder` gave the same transition view. */
@@ -68,21 +62,10 @@ abstract class ReaderComposePage(
/**
* The theme goes on here because the library's page knows nothing of the app's colours.
*
* Under the reader's own night mode rather than the app's: the page sits on the reader
* background, so a light app over a black reader would otherwise draw dark text on black.
* [TachiyomiTheme] reads that from the configuration, which the pager and webtoon adapters
* used to get by inflating their views from a reader-themed context.
*/
@Composable
final override fun BoxScope.Content() {
val context = viewer.readerThemedContext
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
) {
TachiyomiTheme { PageContent() }
}
ReaderThemedContent(viewer.readerThemedContext) { PageContent() }
}
@Composable
@@ -128,36 +111,6 @@ class ErrorPage internal constructor(
}
}
/** `reader_error.xml`, in Compose. */
@Composable
private fun ReaderError(
message: String?,
onRetry: () -> Unit,
onOpenInWebView: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = message ?: stringResource(MR.strings.decode_image_error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry) {
Text(stringResource(MR.strings.action_retry))
}
if (onOpenInWebView != null) {
Button(onClick = onOpenInWebView) {
Text(stringResource(MR.strings.action_open_in_web_view))
}
}
}
}
/** A page still downloading: the reader's progress ring. */
class ProgressPage(viewer: WebGpuViewer) : ReaderComposePage(viewer) {
@@ -228,7 +181,7 @@ class TransitionPage(
} else {
Modifier.padding(horizontal = TRANSITION_SIDE_PADDING.dp)
}
Box(modifier = padding) {
Box(modifier = padding.fillMaxWidth(), contentAlignment = Alignment.TopCenter) {
ChapterTransition(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import eu.kanade.tachiyomi.ui.reader.viewer.DualPageSettings
import eu.kanade.tachiyomi.ui.reader.viewer.NavigationSettings
/**
@@ -16,9 +17,9 @@ data class SimpleLongStripSettings(
*/
data class Page(
val sidePadding: Int,
val dualPageSplit: Boolean,
val dualPageInvert: Boolean,
val dualPageRotateToFit: Boolean,
val dualPageRotateToFitInvert: Boolean,
)
override val dualPageSplit: Boolean,
override val dualPageInvert: Boolean,
override val dualPageRotateToFit: Boolean,
override val dualPageRotateToFitInvert: Boolean,
) : DualPageSettings
}
@@ -5,25 +5,24 @@ import android.widget.LinearLayout
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.calculateChapterGap
import eu.kanade.tachiyomi.util.system.createReaderThemeContext
/**
* RecyclerView Adapter used by this [viewer] to where [ViewerChapters] updates are posted.
*/
class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
class WebtoonAdapter(
val viewer: WebtoonViewer,
private val webtoonItems: WebtoonItems,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/**
* List of currently set items.
*/
var items: List<Any> = emptyList()
private set
var currentChapter: ReaderChapter? = null
val items: List<Any>
get() = webtoonItems.items
/**
* Context that has been wrapped to use the correct theme values based on the
@@ -36,42 +35,9 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
* next/previous chapter to allow seamless transitions.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
val newItems = mutableListOf<Any>()
// Forces chapter transition if there is missing chapters
val prevHasMissingChapters = calculateChapterGap(chapters.currChapter, chapters.prevChapter) > 0
val nextHasMissingChapters = calculateChapterGap(chapters.nextChapter, chapters.currChapter) > 0
// Add previous chapter pages and transition.
chapters.prevChapter?.pages?.let(newItems::addAll)
// Skip transition page if the chapter is loaded & current page is not a transition page
if (prevHasMissingChapters || forceTransition || chapters.prevChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Prev(chapters.currChapter, chapters.prevChapter))
}
// Add current chapter.
val currPages = chapters.currChapter.pages
if (currPages != null) {
newItems.addAll(currPages)
}
currentChapter = chapters.currChapter
// Add next chapter transition and pages.
if (nextHasMissingChapters || forceTransition || chapters.nextChapter?.state !is ReaderChapter.State.Loaded) {
newItems.add(ChapterTransition.Next(chapters.currChapter, chapters.nextChapter))
}
chapters.nextChapter?.pages?.let(newItems::addAll)
updateItems(newItems)
}
private fun updateItems(newItems: List<Any>) {
val result = DiffUtil.calculateDiff(Callback(items, newItems))
items = newItems
result.dispatchUpdatesTo(this)
val oldItems = items
webtoonItems.setChapters(chapters, forceTransition)
DiffUtil.calculateDiff(Callback(oldItems, items)).dispatchUpdatesTo(this)
}
fun refresh() {
@@ -0,0 +1,32 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters
import eu.kanade.tachiyomi.ui.reader.viewer.viewerItems
/**
* Items of a webtoon viewer, where [ViewerChapters] updates are posted.
*/
class WebtoonItems {
/**
* List of currently set items.
*/
var items: List<Any> by mutableStateOf(emptyList())
private set
var currentChapter: ReaderChapter? = null
private set
/**
* Updates the items with the given [chapters]. It handles setting a few pages of the
* next/previous chapter to allow seamless transitions.
*/
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
currentChapter = chapters.currChapter
items = viewerItems(chapters, forceTransition)
}
}
@@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
@@ -9,13 +8,12 @@ import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updateMargins
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.databinding.ReaderErrorBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageErrorView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.ui.reader.viewer.processStripImage
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -26,11 +24,8 @@ import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import logcat.LogPriority
import okio.Buffer
import okio.BufferedSource
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.system.ImageUtil
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
/**
* Holder of the webtoon reader for a single page of a chapter.
@@ -58,7 +53,7 @@ class WebtoonPageHolder(
/**
* Error layout to show when the image fails to load.
*/
private var errorLayout: ReaderErrorBinding? = null
private var errorLayout: ReaderPageErrorView? = null
/**
* Getter to retrieve the height of the recycler view.
@@ -191,7 +186,7 @@ class WebtoonPageHolder(
val buffer = withContext(Dispatchers.IO) { streamFn().use { Buffer().readFrom(it) } }
// Splitting and merging decode the image, which is CPU work
val (source, isAnimated) = withContext(Dispatchers.Default) {
val source = process(buffer)
val source = viewer.simpleLongStrip.page.processStripImage(buffer)
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
Pair(source, isAnimated)
}
@@ -215,36 +210,6 @@ class WebtoonPageHolder(
}
}
private fun process(imageSource: BufferedSource): BufferedSource {
if (viewer.simpleLongStrip.page.dualPageRotateToFit) {
return rotateDualPage(imageSource)
}
if (viewer.simpleLongStrip.page.dualPageSplit) {
val isDoublePage = ImageUtil.isWideImage(imageSource)
if (isDoublePage) {
val upperSide = if (viewer.simpleLongStrip.page.dualPageInvert) {
ImageUtil.Side.LEFT
} else {
ImageUtil.Side.RIGHT
}
return ImageUtil.splitAndMerge(imageSource, upperSide)
}
}
return imageSource
}
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
val isDoublePage = ImageUtil.isWideImage(imageSource)
return if (isDoublePage) {
val rotation = if (viewer.simpleLongStrip.page.dualPageRotateToFitInvert) -90f else 90f
ImageUtil.rotateImage(imageSource, rotation)
} else {
imageSource
}
}
/**
* Called when the page has an error.
*/
@@ -278,34 +243,15 @@ class WebtoonPageHolder(
}
/**
* Initializes a button to retry pages.
* Shows the error of the page.
*/
private fun initErrorLayout(error: Throwable?): ReaderErrorBinding {
if (errorLayout == null) {
errorLayout = ReaderErrorBinding.inflate(LayoutInflater.from(context), frame, true)
errorLayout?.root?.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, (parentHeight * 0.8).toInt())
errorLayout?.actionRetry?.setOnClickListener {
page?.let { it.chapter.pageLoader?.retryPage(it) }
}
private fun initErrorLayout(error: Throwable?) {
val page = page ?: return
val errorLayout = errorLayout ?: ReaderPageErrorView(context).also {
frame.addView(it, FrameLayout.LayoutParams(MATCH_PARENT, (parentHeight * 0.8).toInt()))
errorLayout = it
}
val imageUrl = page?.imageUrl
errorLayout?.actionOpenInWebView?.isVisible = imageUrl != null
if (imageUrl != null) {
if (imageUrl.startsWith("http", true)) {
errorLayout?.actionOpenInWebView?.setOnClickListener {
val sourceId = viewer.activity.viewModel.manga?.source
val intent = WebViewActivity.newIntent(context, imageUrl, sourceId)
context.startActivity(intent)
}
}
}
errorLayout?.errorMessage?.text = with(context) { error?.formattedMessage }
?: context.stringResource(MR.strings.decode_image_error)
return errorLayout!!
errorLayout.bind(page = page, error = error, sourceId = viewer.activity.viewModel.manga?.source)
}
/**
@@ -313,7 +259,7 @@ class WebtoonPageHolder(
*/
private fun removeErrorLayout() {
errorLayout?.let {
frame.removeView(it.root)
frame.removeView(it)
errorLayout = null
}
}
@@ -0,0 +1,121 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webtoon
import eu.kanade.tachiyomi.ui.reader.ReaderActivity
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import tachiyomi.core.common.util.system.logcat
import kotlin.math.abs
/**
* The item a webtoon viewer of [items] is on, shared by the view and Compose viewers. It tells [activity] of each new
* page and preloads the chapters around it. A scroll further than [hideThreshold] hides the menu.
*/
class WebtoonPageTracker(
private val activity: ReaderActivity,
private val items: WebtoonItems,
private val hideThreshold: Int,
) {
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
var currentPage: Any? = null
private set
/**
* Marks the item at [position], the last one whose end is visible, as active.
*/
fun onScrolled(position: Int) {
val item = items.items.getOrNull(position)
val allowPreload = checkAllowPreload(item as? ReaderPage)
if (item != null && currentPage != item) {
currentPage = item
when (item) {
is ReaderPage -> onPageSelected(item, allowPreload)
is ChapterTransition -> onTransitionSelected(item)
}
}
}
/**
* Called when the list scrolled by [dy] pixels, leaving the items from [firstVisible] to [lastEndVisible] on
* screen.
*/
fun onScrolledBy(dy: Float, firstVisible: Int, lastEndVisible: Int) {
if (abs(dy) > hideThreshold && activity.viewModel.state.value.menuVisible) {
activity.hideMenu()
}
if (dy < 0) {
val firstItem = items.items.getOrNull(firstVisible)
if (firstItem is ChapterTransition.Prev && firstItem.to != null) {
activity.requestPreloadChapter(firstItem.to)
}
}
val lastItem = items.items.getOrNull(lastEndVisible)
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
activity.showMenu()
}
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
// Allow preload for
// 1. Going between pages of same chapter
// 2. Next chapter page
return when (page.chapter) {
(currentPage as? ReaderPage)?.chapter -> true
lastChapter() -> true
else -> false
}
}
/**
* Called when a [page] is marked as active. It notifies the activity of the change and requests the preload of
* the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == items.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
val transitionChapter = lastChapter()
if (transitionChapter != null) {
logcat { "Requesting to preload chapter ${transitionChapter.chapter.chapterNumber}" }
activity.requestPreloadChapter(transitionChapter)
}
}
}
/**
* Called when a [transition] is marked as active. It request the preload of the destination chapter of the
* transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
}
}
/**
* The chapter at the end of the list: the next transition's, or that of the next chapter's pages.
*/
private fun lastChapter() = when (val lastItem = items.items.lastOrNull()) {
is ChapterTransition.Next -> lastItem.to
is ReaderPage -> lastItem.chapter
else -> null
}
}
@@ -4,21 +4,12 @@ import android.view.Gravity
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.LinearLayout
import androidx.appcompat.widget.AppCompatButton
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.view.isNotEmpty
import androidx.core.view.isVisible
import com.google.android.material.progressindicator.CircularProgressIndicator
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderTransitionView
import eu.kanade.tachiyomi.ui.reader.viewer.transitionDownloadState
import eu.kanade.tachiyomi.util.system.dpToPx
import kotlinx.coroutines.Job
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
/**
* Holder of the webtoon viewer that contains a chapter transition.
@@ -28,20 +19,8 @@ class WebtoonTransitionHolder(
viewer: WebtoonViewer,
) : WebtoonBaseHolder(layout, viewer) {
private val scope = MainScope()
private var stateJob: Job? = null
private val transitionView = ReaderTransitionView(context)
/**
* View container of the current status of the transition page. Child views will be added
* dynamically.
*/
private var pagesContainer = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
}
init {
layout.layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
layout.orientation = LinearLayout.VERTICAL
@@ -51,95 +30,22 @@ class WebtoonTransitionHolder(
val paddingHorizontal = 32.dpToPx
layout.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical)
val childMargins = 16.dpToPx
val childParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT).apply {
setMargins(0, childMargins, 0, childMargins)
}
layout.addView(transitionView)
layout.addView(pagesContainer, childParams)
}
/**
* Binds the given [transition] with this view holder, subscribing to its state.
* Binds the given [transition] with this view holder.
*/
fun bind(transition: ChapterTransition) {
transitionView.bind(
transition,
viewer.downloadManager,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
transition = transition,
downloadState = viewer.downloadManager.transitionDownloadState(
transition,
viewer.activity.viewModel.manga,
viewer.activity.viewModel.source,
),
onRetry = viewer.activity::requestPreloadChapter,
statusPadding = PaddingValues(vertical = 16.dp),
)
transition.to?.let { observeStatus(it, transition) }
}
/**
* Called when the view is recycled and being added to the view pool.
*/
override fun recycle() {
stateJob?.cancel()
}
/**
* Observes the status of the page list of the next/previous chapter. Whenever there's a new
* state, the pages container is cleaned up before setting the new state.
*/
private fun observeStatus(chapter: ReaderChapter, transition: ChapterTransition) {
stateJob?.cancel()
stateJob = scope.launch {
chapter.stateFlow
.collectLatest { state ->
pagesContainer.removeAllViews()
when (state) {
is ReaderChapter.State.Loading -> setLoading()
is ReaderChapter.State.Error -> setError(state.error, transition)
is ReaderChapter.State.Wait, is ReaderChapter.State.Loaded -> {
// No additional view is added
}
}
pagesContainer.isVisible = pagesContainer.isNotEmpty()
}
}
}
/**
* Sets the loading state on the pages container.
*/
private fun setLoading() {
val progress = CircularProgressIndicator(context)
progress.isIndeterminate = true
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_loading)
}
pagesContainer.addView(progress)
pagesContainer.addView(textView)
}
/**
* Sets the error state on the pages container.
*/
private fun setError(error: Throwable, transition: ChapterTransition) {
val textView = AppCompatTextView(context).apply {
wrapContent()
text = context.stringResource(MR.strings.transition_pages_error, error.message ?: "")
}
val retryBtn = AppCompatButton(context).apply {
wrapContent()
text = context.stringResource(MR.strings.action_retry)
setOnClickListener {
val toChapter = transition.to
if (toChapter != null) {
viewer.activity.requestPreloadChapter(toChapter)
}
}
}
pagesContainer.addView(textView)
pagesContainer.addView(retryBtn)
}
}
@@ -85,17 +85,18 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
val general: GeneralSettings
get() = settingsViewModel.general.value
private val webtoonItems = WebtoonItems()
/**
* Adapter of the recycler view.
*/
private val adapter = WebtoonAdapter(this)
private val adapter = WebtoonAdapter(this, webtoonItems)
/**
* Currently active item. It can be a chapter page or a chapter transition.
*/
private var currentPage: Any? = null
private val threshold: Int by lazy { readerPreferences.readerHideThreshold.get().threshold }
private val tracker = WebtoonPageTracker(
activity,
webtoonItems,
readerPreferences.readerHideThreshold.get().threshold,
)
init {
recycler.sidePaddingPercent = simpleLongStrip.page.sidePadding
@@ -110,24 +111,11 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
onScrolled()
if ((dy > threshold || dy < -threshold) && activity.viewModel.state.value.menuVisible) {
activity.hideMenu()
}
if (dy < 0) {
val firstIndex = layoutManager.findFirstVisibleItemPosition()
val firstItem = adapter.items.getOrNull(firstIndex)
if (firstItem is ChapterTransition.Prev && firstItem.to != null) {
activity.requestPreloadChapter(firstItem.to)
}
}
val lastIndex = layoutManager.findLastEndVisibleItemPosition()
val lastItem = adapter.items.getOrNull(lastIndex)
if (lastItem is ChapterTransition.Next && lastItem.to == null) {
activity.showMenu()
}
tracker.onScrolledBy(
dy = dy.toFloat(),
firstVisible = layoutManager.findFirstVisibleItemPosition(),
lastEndVisible = layoutManager.findLastEndVisibleItemPosition(),
)
}
},
)
@@ -185,26 +173,6 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
frame.addView(recycler)
}
private fun checkAllowPreload(page: ReaderPage?): Boolean {
// Page is transition page - preload allowed
page ?: return true
// Initial opening - preload allowed
currentPage ?: return true
val nextItem = adapter.items.getOrNull(adapter.items.size - 1)
val nextChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as? ReaderPage)?.chapter
// Allow preload for
// 1. Going between pages of same chapter
// 2. Next chapter page
return when (page.chapter) {
(currentPage as? ReaderPage)?.chapter -> true
nextChapter -> true
else -> false
}
}
/** The page images' colours, for pages bound later - see [LocalPageColorFilter]. */
var pageColorFilter: ColorFilter? = null
private set
@@ -235,46 +203,11 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
scope.cancel()
}
/**
* Called from the RecyclerView listener when a [page] is marked as active. It notifies the
* activity of the change and requests the preload of the next chapter if this is the last page.
*/
private fun onPageSelected(page: ReaderPage, allowPreload: Boolean) {
val pages = page.chapter.pages ?: return
logcat { "onPageSelected: ${page.number}/${pages.size}" }
activity.onPageSelected(page)
// Preload next chapter once we're within the last 5 pages of the current chapter
val inPreloadRange = pages.size - page.number < 5
if (inPreloadRange && allowPreload && page.chapter == adapter.currentChapter) {
logcat { "Request preload next chapter because we're at page ${page.number} of ${pages.size}" }
val nextItem = adapter.items.getOrNull(adapter.items.size - 1)
val transitionChapter = (nextItem as? ChapterTransition.Next)?.to ?: (nextItem as?ReaderPage)?.chapter
if (transitionChapter != null) {
logcat { "Requesting to preload chapter ${transitionChapter.chapter.chapterNumber}" }
activity.requestPreloadChapter(transitionChapter)
}
}
}
/**
* Called from the RecyclerView listener when a [transition] is marked as active. It request the
* preload of the destination chapter of the transition.
*/
private fun onTransitionSelected(transition: ChapterTransition) {
logcat { "onTransitionSelected: $transition" }
val toChapter = transition.to
if (toChapter != null) {
logcat { "Request preload destination chapter because we're on the transition" }
activity.requestPreloadChapter(toChapter)
}
}
/**
* Tells this viewer to set the given [chapters] as active.
*/
override fun setChapters(chapters: ViewerChapters) {
val forceTransition = general.alwaysShowChapterTransition || currentPage is ChapterTransition
val forceTransition = general.alwaysShowChapterTransition || tracker.currentPage is ChapterTransition
adapter.setChapters(chapters, forceTransition)
if (recycler.isGone) {
@@ -300,17 +233,8 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
}
}
fun onScrolled(pos: Int? = null) {
val position = pos ?: layoutManager.findLastEndVisibleItemPosition()
val item = adapter.items.getOrNull(position)
val allowPreload = checkAllowPreload(item as? ReaderPage)
if (item != null && currentPage != item) {
currentPage = item
when (item) {
is ReaderPage -> onPageSelected(item, allowPreload)
is ChapterTransition -> onTransitionSelected(item)
}
}
private fun onScrolled(pos: Int? = null) {
tracker.onScrolled(pos ?: layoutManager.findLastEndVisibleItemPosition())
}
/**
-32
View File
@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center">
<TextView
android:id="@+id/error_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/decode_image_error"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
android:id="@+id/action_retry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/action_retry" />
<eu.kanade.tachiyomi.ui.reader.viewer.ReaderButton
android:id="@+id/action_open_in_web_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:text="@string/action_open_in_web_view"
android:visibility="gone" />
</LinearLayout>