diff --git a/app/src/main/java/eu/kanade/presentation/reader/ReaderError.kt b/app/src/main/java/eu/kanade/presentation/reader/ReaderError.kt new file mode 100644 index 0000000000..085f9c9edc --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/reader/ReaderError.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/DualPageSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/DualPageSettings.kt new file mode 100644 index 0000000000..b1e24f2ceb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/DualPageSettings.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/PageImageProcessing.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/PageImageProcessing.kt new file mode 100644 index 0000000000..8f9701a5aa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/PageImageProcessing.kt @@ -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) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderButton.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderButton.kt deleted file mode 100644 index 382fc41efa..0000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderButton.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageError.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageError.kt new file mode 100644 index 0000000000..c6586dc44e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageError.kt @@ -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(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)?, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderThemedContent.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderThemedContent.kt new file mode 100644 index 0000000000..72b6227b60 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderThemedContent.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionPage.kt new file mode 100644 index 0000000000..c2cb1232a5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionPage.kt @@ -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 -> {} + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt index d42492b443..a340dec730 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderTransitionView.kt @@ -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, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/TransitionDownloadState.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/TransitionDownloadState.kt new file mode 100644 index 0000000000..980b9a922e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/TransitionDownloadState.kt @@ -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, + ) + } + }, + ) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerItems.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerItems.kt new file mode 100644 index 0000000000..f24df79d32 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ViewerItems.kt @@ -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? = chapters.currChapter.pages, +): MutableList { + val items = mutableListOf() + + // 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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerItems.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerItems.kt new file mode 100644 index 0000000000..eb92e0c52d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerItems.kt @@ -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 by mutableStateOf(emptyList()) + private set + + /** + * Holds preprocessed items so they don't get removed when changing chapter + */ + private var preprocessed: List = 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?) +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt index 8b38c62583..926e93037b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageHolder.kt @@ -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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageTracker.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageTracker.kt new file mode 100644 index 0000000000..d1851a5664 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerPageTracker.kt @@ -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() + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt index 0ec4cd803f..849daaf4fc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerTransitionHolder.kt @@ -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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt index ae0382ed56..57c4b5626f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewer.kt @@ -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 { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt index 2ee4b907f2..09a916626d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/PagerViewerAdapter.kt @@ -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 = mutableListOf() - private set - - /** - * Holds preprocessed items so they don't get removed when changing chapter - */ - private var preprocessed: List = emptyList() - - var nextTransition: ChapterTransition.Next? = null - private set - - var currentChapter: ReaderChapter? = null + val items: List + 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() - - // 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().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() - items.removeAll(insertPages) + pagerItems.cleanupPageSplit() notifyDataSetChanged() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/SimplePagedSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/SimplePagedSettings.kt index cd027d4538..101f048460 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/SimplePagedSettings.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/pager/SimplePagedSettings.kt @@ -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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt index 6df7366bf0..53ff2d0bfa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/ReaderChromePages.kt @@ -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, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/SimpleLongStripSettings.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/SimpleLongStripSettings.kt index e8e7469dc5..a18976ea2a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/SimpleLongStripSettings.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/SimpleLongStripSettings.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt index a80a57d5a3..a3e40b57be 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonAdapter.kt @@ -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() { +class WebtoonAdapter( + val viewer: WebtoonViewer, + private val webtoonItems: WebtoonItems, +) : RecyclerView.Adapter() { /** * List of currently set items. */ - var items: List = emptyList() - private set - - var currentChapter: ReaderChapter? = null + val items: List + 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() - - // 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) { - 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() { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonItems.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonItems.kt new file mode 100644 index 0000000000..1f42a21302 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonItems.kt @@ -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 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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt index 2d0e884396..39cd89f3ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageHolder.kt @@ -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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageTracker.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageTracker.kt new file mode 100644 index 0000000000..85a6f7b680 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonPageTracker.kt @@ -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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt index a13632032f..e27e276062 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonTransitionHolder.kt @@ -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) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt index f47158aef0..8edde6f3ec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webtoon/WebtoonViewer.kt @@ -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()) } /** diff --git a/app/src/main/res/layout/reader_error.xml b/app/src/main/res/layout/reader_error.xml deleted file mode 100644 index 44e6c7322f..0000000000 --- a/app/src/main/res/layout/reader_error.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - -