refactor: Use Compose for reader chapter transition

Co-authored-by: arkon <arkon@users.noreply.github.com>
This commit is contained in:
Ahmad Ansori Palembani 2024-12-24 12:19:24 +07:00
parent 0049653355
commit 031e30e227
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
9 changed files with 398 additions and 126 deletions

View file

@ -11,6 +11,8 @@ import yokai.core.archive.ArchiveReader
*/ */
internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader() { internal class ArchivePageLoader(private val reader: ArchiveReader) : PageLoader() {
override val isLocal: Boolean = true
/** /**
* Recycles this loader and the open archive. * Recycles this loader and the open archive.
*/ */

View file

@ -11,6 +11,8 @@ import eu.kanade.tachiyomi.util.system.ImageUtil
*/ */
class DirectoryPageLoader(val file: UniFile) : PageLoader() { class DirectoryPageLoader(val file: UniFile) : PageLoader() {
override val isLocal: Boolean = true
/** /**
* Returns the pages found on this directory ordered with a natural comparator. * Returns the pages found on this directory ordered with a natural comparator.
*/ */

View file

@ -24,6 +24,8 @@ class DownloadPageLoader(
private val downloadProvider: DownloadProvider, private val downloadProvider: DownloadProvider,
) : PageLoader() { ) : PageLoader() {
override val isLocal: Boolean = true
// Needed to open input streams // Needed to open input streams
private val context: Application by injectLazy() private val context: Application by injectLazy()

View file

@ -10,6 +10,8 @@ import yokai.core.archive.ArchiveReader
*/ */
class EpubPageLoader(reader: ArchiveReader) : PageLoader() { class EpubPageLoader(reader: ArchiveReader) : PageLoader() {
override val isLocal: Boolean = true
/** /**
* The epub file. * The epub file.
*/ */

View file

@ -8,6 +8,9 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.launchIO
import eu.kanade.tachiyomi.util.system.withIOContext 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.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -19,9 +22,6 @@ import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get 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. * Loader used to load chapters from an online source.
@ -33,6 +33,8 @@ class HttpPageLoader(
private val preferences: PreferencesHelper = Injekt.get(), private val preferences: PreferencesHelper = Injekt.get(),
) : PageLoader() { ) : PageLoader() {
override val isLocal: Boolean = false
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/** /**

View file

@ -9,6 +9,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
*/ */
abstract class PageLoader { abstract class PageLoader {
abstract val isLocal: Boolean
/** /**
* Whether this loader has been already recycled. * Whether this loader has been already recycled.
*/ */

View file

@ -1,32 +1,31 @@
package eu.kanade.tachiyomi.ui.reader.viewer package eu.kanade.tachiyomi.ui.reader.viewer
import android.content.Context import android.content.Context
import android.text.SpannableStringBuilder
import android.text.style.ImageSpan
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.LinearLayout import androidx.compose.material3.LocalContentColor
import androidx.annotation.ColorInt import androidx.compose.material3.LocalTextStyle
import androidx.core.text.bold import androidx.compose.material3.MaterialTheme
import androidx.core.text.buildSpannedString import androidx.compose.runtime.Composable
import androidx.core.text.inSpans import androidx.compose.runtime.CompositionLocalProvider
import androidx.core.view.isVisible import androidx.compose.runtime.getValue
import eu.kanade.tachiyomi.R 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.download.DownloadManager
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.ReaderTransitionViewBinding import eu.kanade.tachiyomi.databinding.ReaderTransitionViewBinding
import eu.kanade.tachiyomi.domain.manga.models.Manga import eu.kanade.tachiyomi.domain.manga.models.Manga
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.util.chapter.ChapterUtil.Companion.preferredChapterName import eu.kanade.tachiyomi.util.isLocal
import eu.kanade.tachiyomi.util.system.contextCompatDrawable
import eu.kanade.tachiyomi.util.system.dpToPx
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import yokai.i18n.MR import yokai.presentation.reader.ChapterTransition
import yokai.util.lang.getString import yokai.presentation.theme.YokaiTheme
import kotlin.math.roundToInt
class ReaderTransitionView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : 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 = private val binding: ReaderTransitionViewBinding =
ReaderTransitionViewBinding.inflate(LayoutInflater.from(context), this, true) 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?) { fun bind(transition: ChapterTransition, downloadManager: DownloadManager, manga: Manga?) {
manga ?: return data = if (manga != null) {
when (transition) { Data(
is ChapterTransition.Prev -> bindPrevChapterTransition(transition, downloadManager, manga) manga = manga,
is ChapterTransition.Next -> bindNextChapterTransition(transition, downloadManager, manga) transition = transition,
} currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = manga.isLocal() ||
missingChapterWarning(transition) transition.to?.chapter?.let { goingToChapter ->
} downloadManager.isChapterDownloaded(
chapter = goingToChapter,
/** manga = manga,
* Binds a previous chapter transition on this view and subscribes to the page load status. skipCache = true,
*/ )
private fun bindPrevChapterTransition( } ?: false,
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")
}
} else { } else {
binding.upperText.textAlignment = TEXT_ALIGNMENT_CENTER null
binding.upperText.text = context.getString(MR.strings.theres_no_previous_chapter)
} }
} }
/** @Composable
* Binds a next chapter transition on this view and subscribes to the load status. override fun Content() {
*/ data?.let {
private fun bindNextChapterTransition( YokaiTheme {
transition: ChapterTransition, CompositionLocalProvider (
downloadManager: DownloadManager, LocalTextStyle provides MaterialTheme.typography.bodySmall,
manga: Manga, LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) { ) {
val nextChapter = transition.to ChapterTransition(
manga = it.manga,
binding.lowerText.isVisible = nextChapter != null transition = it.transition,
if (nextChapter != null) { currChapterDownloaded = it.currChapterDownloaded,
binding.upperText.textAlignment = TEXT_ALIGNMENT_TEXT_START goingToChapterDownloaded = it.goingToChapterDownloaded,
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")
} }
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) { private data class Data(
val icon = context.contextCompatDrawable( val manga: Manga,
if (isDownloaded) R.drawable.ic_file_download_24dp else R.drawable.ic_cloud_24dp, val transition: ChapterTransition,
) val currChapterDownloaded: Boolean,
?.mutate() val goingToChapterDownloaded: Boolean,
?.apply { )
val size = binding.lowerText.textSize + 4f.dpToPx }
setTint(binding.lowerText.currentTextColor)
setBounds(0, 0, size.roundToInt(), size.roundToInt()) fun missingChapterCount(transition: ChapterTransition): Int {
} ?: return if (transition.to == null) {
append(" ") return 0
inSpans(ImageSpan(icon)) { append("image") } }
}
val hasMissingChapters = when (transition) {
fun setTextColors(@ColorInt color: Int) { is ChapterTransition.Prev -> hasMissingChapters(transition.from, transition.to)
binding.upperText.setTextColor(color) is ChapterTransition.Next -> hasMissingChapters(transition.to, transition.from)
binding.warningText.setTextColor(color) }
binding.lowerText.setTextColor(color)
} if (!hasMissingChapters) {
return 0
private fun missingChapterWarning(transition: ChapterTransition) { }
if (transition.to == null) {
binding.warning.isVisible = false val chapterDifference = when (transition) {
return is ChapterTransition.Prev -> calculateChapterDifference(transition.from, transition.to)
} is ChapterTransition.Next -> calculateChapterDifference(transition.to, transition.from)
}
val hasMissingChapters = when (transition) {
is ChapterTransition.Prev -> hasMissingChapters(transition.from, transition.to) return chapterDifference.toInt()
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
}
} }

View file

@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.util.chapter
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.widget.TextView import android.widget.TextView
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.ColorUtils import androidx.core.graphics.ColorUtils
import androidx.core.widget.TextViewCompat import androidx.core.widget.TextViewCompat
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat 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 eu.kanade.tachiyomi.util.system.timeSpanFromNow
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.DecimalFormatSymbols import java.text.DecimalFormatSymbols
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import yokai.i18n.MR import yokai.i18n.MR
import yokai.util.lang.getString import yokai.util.lang.getString
@ -182,5 +186,12 @@ class ChapterUtil {
name name
} }
} }
@Composable
fun Chapter.preferredChapterName(manga: Manga): String {
val preferences: PreferencesHelper = Injekt.get()
val context = LocalContext.current
return preferredChapterName(context, manga, preferences)
}
} }
} }

View file

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