diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt index ad78920502..dfa63cbba2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ArchivePageLoader.kt @@ -11,6 +11,8 @@ import yokai.core.archive.ArchiveReader */ internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader() { + override val isLocal: Boolean = true + /** * Recycles this loader and the open archive. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt index e6557c5a13..ba4a10bd6e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DirectoryPageLoader.kt @@ -11,6 +11,8 @@ import eu.kanade.tachiyomi.util.system.ImageUtil */ class DirectoryPageLoader(val file: UniFile) : PageLoader() { + override val isLocal: Boolean = true + /** * Returns the pages found on this directory ordered with a natural comparator. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index f2c2ea8a46..6535ce9847 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -24,6 +24,8 @@ class DownloadPageLoader( private val downloadProvider: DownloadProvider, ) : PageLoader() { + override val isLocal: Boolean = true + // Needed to open input streams private val context: Application by injectLazy() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt index 53e0f26e74..0823453d1c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/EpubPageLoader.kt @@ -10,6 +10,8 @@ import yokai.core.archive.ArchiveReader */ class EpubPageLoader(reader: ArchiveReader) : PageLoader() { + override val isLocal: Boolean = true + /** * The epub file. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt index 130a7fee57..ce32b60d34 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt @@ -8,6 +8,9 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.withIOContext +import java.util.concurrent.PriorityBlockingQueue +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.min import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,9 +22,6 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.suspendCancellableCoroutine import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -import java.util.concurrent.* -import java.util.concurrent.atomic.* -import kotlin.math.min /** * Loader used to load chapters from an online source. @@ -33,6 +33,8 @@ class HttpPageLoader( private val preferences: PreferencesHelper = Injekt.get(), ) : PageLoader() { + override val isLocal: Boolean = false + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt index 720e81a43c..3362ff3caa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/PageLoader.kt @@ -9,6 +9,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage */ abstract class PageLoader { + abstract val isLocal: Boolean + /** * Whether this loader has been already recycled. */ 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 3693274498..0e7b2602b8 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 @@ -1,32 +1,31 @@ package eu.kanade.tachiyomi.ui.reader.viewer import android.content.Context -import android.text.SpannableStringBuilder -import android.text.style.ImageSpan import android.util.AttributeSet import android.view.LayoutInflater -import android.widget.LinearLayout -import androidx.annotation.ColorInt -import androidx.core.text.bold -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans -import androidx.core.view.isVisible -import eu.kanade.tachiyomi.R +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.AbstractComposeView import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.databinding.ReaderTransitionViewBinding import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition -import eu.kanade.tachiyomi.util.chapter.ChapterUtil.Companion.preferredChapterName -import eu.kanade.tachiyomi.util.system.contextCompatDrawable -import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.isLocal import uy.kohesive.injekt.injectLazy -import yokai.i18n.MR -import yokai.util.lang.getString -import kotlin.math.roundToInt +import yokai.presentation.reader.ChapterTransition +import yokai.presentation.theme.YokaiTheme class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : - LinearLayout(context, attrs) { + AbstractComposeView(context, attrs) { + + private var data: Data? by mutableStateOf(null) private val binding: ReaderTransitionViewBinding = ReaderTransitionViewBinding.inflate(LayoutInflater.from(context), this, true) @@ -37,119 +36,70 @@ class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: At } fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?) { - manga ?: return - when (transition) { - is ChapterTransition.Prev -> bindPrevChapterTransition(transition, downloadManager, manga) - is ChapterTransition.Next -> bindNextChapterTransition(transition, downloadManager, manga) - } - - missingChapterWarning(transition) - } - - /** - * Binds a previous chapter transition on this view and subscribes to the page load status. - */ - private fun bindPrevChapterTransition( - transition: ChapterTransition, - downloadManager: DownloadManager, - manga: Manga, - ) { - val prevChapter = transition.to - - binding.lowerText.isVisible = prevChapter != null - if (prevChapter != null) { - binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START - val isPrevDownloaded = downloadManager.isChapterDownloaded(prevChapter.chapter, manga) - val isCurrentDownloaded = downloadManager.isChapterDownloaded(transition.from.chapter, manga) - binding.upperText.text = buildSpannedString { - bold { append(context.getString(MR.strings.previous_title)) } - append("\n${prevChapter.chapter.preferredChapterName(context, manga, preferences)}") - if (isPrevDownloaded != isCurrentDownloaded) addDLImageSpan(isPrevDownloaded) - } - binding.lowerText.text = buildSpannedString { - bold { append(context.getString(MR.strings.current_chapter)) } - val name = transition.from.chapter.preferredChapterName(context, manga, preferences) - append("\n$name") - } + data = if (manga != null) { + Data( + manga = manga, + transition = transition, + currChapterDownloaded = transition.from.pageLoader?.isLocal == true, + goingToChapterDownloaded = manga.isLocal() || + transition.to?.chapter?.let { goingToChapter -> + downloadManager.isChapterDownloaded( + chapter = goingToChapter, + manga = manga, + skipCache = true, + ) + } ?: false, + ) } else { - binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER - binding.upperText.text = context.getString(MR.strings.theres_no_previous_chapter) + null } } - /** - * Binds a next chapter transition on this view and subscribes to the load status. - */ - private fun bindNextChapterTransition( - transition: ChapterTransition, - downloadManager: DownloadManager, - manga: Manga, - ) { - val nextChapter = transition.to - - binding.lowerText.isVisible = nextChapter != null - if (nextChapter != null) { - binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START - val isCurrentDownloaded = downloadManager.isChapterDownloaded(transition.from.chapter, manga) - val isNextDownloaded = downloadManager.isChapterDownloaded(nextChapter.chapter, manga) - binding.upperText.text = buildSpannedString { - bold { append(context.getString(MR.strings.finished_chapter)) } - val name = transition.from.chapter.preferredChapterName(context, manga, preferences) - append("\n$name") + @Composable + override fun Content() { + data?.let { + YokaiTheme { + CompositionLocalProvider ( + LocalTextStyle provides MaterialTheme.typography.bodySmall, + LocalContentColor provides MaterialTheme.colorScheme.onBackground, + ) { + ChapterTransition( + manga = it.manga, + transition = it.transition, + currChapterDownloaded = it.currChapterDownloaded, + goingToChapterDownloaded = it.goingToChapterDownloaded, + ) + } } - binding.lowerText.text = buildSpannedString { - bold { append(context.getString(MR.strings.next_title)) } - append("\n${nextChapter.chapter.preferredChapterName(context, manga, preferences)}") - if (isNextDownloaded != isCurrentDownloaded) addDLImageSpan(isNextDownloaded) - } - } else { - binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER - binding.upperText.text = context.getString(MR.strings.theres_no_next_chapter) } } - private fun SpannableStringBuilder.addDLImageSpan(isDownloaded: Boolean) { - val icon = context.contextCompatDrawable( - if (isDownloaded) R.drawable.ic_file_download_24dp else R.drawable.ic_cloud_24dp, - ) - ?.mutate() - ?.apply { - val size = binding.lowerText.textSize + 4f.dpToPx - setTint(binding.lowerText.currentTextColor) - setBounds(0, 0, size.roundToInt(), size.roundToInt()) - } ?: return - append(" ") - inSpans(ImageSpan(icon)) { append("image") } - } - - fun setTextColors(@ColorInt color: Int) { - binding.upperText.setTextColor(color) - binding.warningText.setTextColor(color) - binding.lowerText.setTextColor(color) - } - - private fun missingChapterWarning(transition: ChapterTransition) { - if (transition.to == null) { - binding.warning.isVisible = false - return - } - - val hasMissingChapters = when (transition) { - is ChapterTransition.Prev -> hasMissingChapters(transition.from, transition.to) - is ChapterTransition.Next -> hasMissingChapters(transition.to, transition.from) - } - - if (!hasMissingChapters) { - binding.warning.isVisible = false - return - } - - val chapterDifference = when (transition) { - is ChapterTransition.Prev -> calculateChapterDifference(transition.from, transition.to) - is ChapterTransition.Next -> calculateChapterDifference(transition.to, transition.from) - } - - binding.warningText.text = context.getString(MR.plurals.missing_chapters_warning, chapterDifference.toInt(), chapterDifference.toInt()) - binding.warning.isVisible = true - } + private data class Data( + val manga: Manga, + val transition: ChapterTransition, + val currChapterDownloaded: Boolean, + val goingToChapterDownloaded: Boolean, + ) +} + +fun missingChapterCount(transition: ChapterTransition): Int { + if (transition.to == null) { + return 0 + } + + val hasMissingChapters = when (transition) { + is ChapterTransition.Prev -> hasMissingChapters(transition.from, transition.to) + is ChapterTransition.Next -> hasMissingChapters(transition.to, transition.from) + } + + if (!hasMissingChapters) { + return 0 + } + + val chapterDifference = when (transition) { + is ChapterTransition.Prev -> calculateChapterDifference(transition.from, transition.to) + is ChapterTransition.Next -> calculateChapterDifference(transition.to, transition.from) + } + + return chapterDifference.toInt() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt index d5bd0ac97c..33d9efdb30 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterUtil.kt @@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.util.chapter import android.content.Context import android.content.res.ColorStateList import android.widget.TextView +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import androidx.core.graphics.ColorUtils import androidx.core.widget.TextViewCompat import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat @@ -19,6 +21,8 @@ import eu.kanade.tachiyomi.util.system.getResourceColor import eu.kanade.tachiyomi.util.system.timeSpanFromNow import java.text.DecimalFormat import java.text.DecimalFormatSymbols +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import yokai.i18n.MR import yokai.util.lang.getString @@ -182,5 +186,12 @@ class ChapterUtil { name } } + + @Composable + fun Chapter.preferredChapterName(manga: Manga): String { + val preferences: PreferencesHelper = Injekt.get() + val context = LocalContext.current + return preferredChapterName(context, manga, preferences) + } } } diff --git a/app/src/main/java/yokai/presentation/reader/ChapterTransition.kt b/app/src/main/java/yokai/presentation/reader/ChapterTransition.kt new file mode 100644 index 0000000000..a6c1c403f7 --- /dev/null +++ b/app/src/main/java/yokai/presentation/reader/ChapterTransition.kt @@ -0,0 +1,299 @@ +package yokai.presentation.reader + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Warning +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.icerock.moko.resources.compose.pluralStringResource +import dev.icerock.moko.resources.compose.stringResource +import eu.kanade.tachiyomi.data.database.models.Chapter +import eu.kanade.tachiyomi.domain.manga.models.Manga +import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition +import eu.kanade.tachiyomi.ui.reader.viewer.missingChapterCount +import eu.kanade.tachiyomi.util.chapter.ChapterUtil.Companion.preferredChapterName +import kotlinx.collections.immutable.persistentMapOf +import yokai.i18n.MR +import yokai.presentation.core.util.secondaryItemAlpha + +@Composable +fun ChapterTransition( + manga: Manga, + transition: ChapterTransition, + currChapterDownloaded: Boolean, + goingToChapterDownloaded: Boolean, +) { + val currChapter = transition.from.chapter + val goingToChapter = transition.to?.chapter + val chapterGap = missingChapterCount(transition) + + ProvideTextStyle(MaterialTheme.typography.bodyMedium) { + when (transition) { + is ChapterTransition.Prev -> { + TransitionText( + manga = manga, + topLabel = stringResource(MR.strings.previous_title), + topChapter = goingToChapter, + topChapterDownloaded = goingToChapterDownloaded, + bottomLabel = stringResource(MR.strings.current_chapter), + bottomChapter = currChapter, + bottomChapterDownloaded = currChapterDownloaded, + fallbackLabel = stringResource(MR.strings.theres_no_previous_chapter), + chapterGap = chapterGap, + ) + } + is ChapterTransition.Next -> { + TransitionText( + manga = manga, + topLabel = stringResource(MR.strings.finished_chapter), + topChapter = currChapter, + topChapterDownloaded = currChapterDownloaded, + bottomLabel = stringResource(MR.strings.next_title), + bottomChapter = goingToChapter, + bottomChapterDownloaded = goingToChapterDownloaded, + fallbackLabel = stringResource(MR.strings.theres_no_next_chapter), + chapterGap = chapterGap, + ) + } + } + } +} + +@Composable +private fun TransitionText( + manga: Manga, + topLabel: String, + topChapter: Chapter?, + topChapterDownloaded: Boolean, + bottomLabel: String, + bottomChapter: Chapter?, + bottomChapterDownloaded: Boolean, + fallbackLabel: String, + chapterGap: Int, +) { + Column ( + modifier = Modifier + .widthIn(max = 460.dp) + .fillMaxWidth(), + ) { + if (topChapter != null) { + ChapterText( + header = topLabel, + name = topChapter.preferredChapterName(manga), + scanlator = topChapter.scanlator, + otherDownloaded = bottomChapterDownloaded, + downloaded = topChapterDownloaded, + ) + + Spacer(Modifier.height(VerticalSpacerSize)) + } else { + NoChapterNotification( + text = fallbackLabel, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + + if (bottomChapter != null) { + if (chapterGap > 0) { + ChapterGapWarning( + gapCount = chapterGap, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + + Spacer(Modifier.height(VerticalSpacerSize)) + + ChapterText( + header = bottomLabel, + name = bottomChapter.preferredChapterName(manga), + scanlator = bottomChapter.scanlator, + otherDownloaded = topChapterDownloaded, + downloaded = bottomChapterDownloaded, + ) + } else { + NoChapterNotification( + text = fallbackLabel, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } +} + +@Composable +private fun NoChapterNotification( + text: String, + modifier: Modifier = Modifier, +) { + OutlinedCard ( + modifier = modifier, + colors = CardColor, + ) { + Row ( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Info, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + ) + + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ChapterGapWarning( + gapCount: Int, + modifier: Modifier = Modifier, +) { + OutlinedCard( + modifier = modifier, + colors = CardColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Warning, + tint = MaterialTheme.colorScheme.error, + contentDescription = null, + ) + + Text( + text = pluralStringResource(MR.plurals.missing_chapters_warning, quantity = gapCount, gapCount), + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ChapterHeaderText( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + modifier = modifier, + style = MaterialTheme.typography.titleMedium, + ) +} + +@Composable +private fun ChapterText( + header: String, + name: String, + scanlator: String?, + otherDownloaded: Boolean, + downloaded: Boolean, +) { + Column { + ChapterHeaderText( + text = header, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Text( + text = buildAnnotatedString { + if (downloaded || otherDownloaded) { + if (downloaded) { + appendInlineContent(DOWNLOADED_ICON_ID) + } else { + appendInlineContent(ONLINE_ICON_ID) + } + append(' ') + } + append(name) + }, + fontSize = 20.sp, + maxLines = 5, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleLarge, + inlineContent = persistentMapOf( + DOWNLOADED_ICON_ID to InlineTextContent( + Placeholder( + width = 22.sp, + height = 22.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = stringResource(MR.strings.downloaded), + ) + }, + ONLINE_ICON_ID to InlineTextContent( + Placeholder( + width = 22.sp, + height = 22.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + imageVector = Icons.Filled.Cloud, + contentDescription = stringResource(MR.strings.not_downloaded), + ) + }, + ), + ) + + scanlator?.let { + Text( + text = it, + modifier = Modifier + .secondaryItemAlpha() + .padding(top = 2.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +private val CardColor: CardColors + @Composable + get() = CardDefaults.outlinedCardColors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + ) + +private val VerticalSpacerSize = 24.dp +private const val DOWNLOADED_ICON_ID = "downloaded" +private const val ONLINE_ICON_ID = "online"