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() {
override val isLocal: Boolean = true
/**
* 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() {
override val isLocal: Boolean = true
/**
* Returns the pages found on this directory ordered with a natural comparator.
*/

View file

@ -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()

View file

@ -10,6 +10,8 @@ import yokai.core.archive.ArchiveReader
*/
class EpubPageLoader(reader: ArchiveReader) : PageLoader() {
override val isLocal: Boolean = true
/**
* 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.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)
/**

View file

@ -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.
*/

View file

@ -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()
}

View file

@ -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)
}
}
}

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"