Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6391c9376c | |||
| dcec339477 | |||
| 1b01c894ad |
@@ -216,6 +216,7 @@ dependencies {
|
|||||||
implementation(projects.coreMetadata)
|
implementation(projects.coreMetadata)
|
||||||
implementation(projects.sourceApi)
|
implementation(projects.sourceApi)
|
||||||
implementation(projects.sourceLocal)
|
implementation(projects.sourceLocal)
|
||||||
|
implementation(libs.jsoup)
|
||||||
implementation(projects.data)
|
implementation(projects.data)
|
||||||
implementation(projects.domain)
|
implementation(projects.domain)
|
||||||
implementation(projects.presentationCore)
|
implementation(projects.presentationCore)
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
|
|||||||
themes.map { (labelRes, value) ->
|
themes.map { (labelRes, value) ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = readerTheme == value,
|
selected = readerTheme == value,
|
||||||
onClick = { viewModel.preferences.readerTheme.set(value) },
|
onClick = {
|
||||||
|
viewModel.preferences.readerTheme.set(value)
|
||||||
|
viewModel.requestTextSettingsReload()
|
||||||
|
},
|
||||||
label = { Text(stringResource(labelRes)) },
|
label = { Text(stringResource(labelRes)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
@@ -33,15 +32,11 @@ fun ReaderSettingsDialog(
|
|||||||
stringResource(MR.strings.pref_category_text_reader),
|
stringResource(MR.strings.pref_category_text_reader),
|
||||||
)
|
)
|
||||||
val pagerState = rememberPagerState { tabTitles.size }
|
val pagerState = rememberPagerState { tabTitles.size }
|
||||||
val initialTextStyle = remember { viewModel.preferences.textReaderStyle() }
|
|
||||||
|
|
||||||
BoxWithConstraints {
|
BoxWithConstraints {
|
||||||
TabbedDialog(
|
TabbedDialog(
|
||||||
modifier = Modifier.heightIn(max = maxHeight * 0.75f),
|
modifier = Modifier.heightIn(max = maxHeight * 0.75f),
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
if (viewModel.preferences.textReaderStyle() != initialTextStyle) {
|
viewModel.flushTextSettingsReload()
|
||||||
viewModel.onTextSettingsChanged()
|
|
||||||
}
|
|
||||||
onDismissRequest()
|
onDismissRequest()
|
||||||
onShowMenus()
|
onShowMenus()
|
||||||
},
|
},
|
||||||
@@ -51,7 +46,7 @@ fun ReaderSettingsDialog(
|
|||||||
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
|
val window = (LocalView.current.parent as? DialogWindowProvider)?.window
|
||||||
|
|
||||||
LaunchedEffect(pagerState.currentPage) {
|
LaunchedEffect(pagerState.currentPage) {
|
||||||
if (pagerState.currentPage == 2) {
|
if (pagerState.currentPage == 2 || pagerState.currentPage == 3) {
|
||||||
window?.setDimAmount(0f)
|
window?.setDimAmount(0f)
|
||||||
onHideMenus()
|
onHideMenus()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
|
|||||||
SliderItem(
|
SliderItem(
|
||||||
label = stringResource(MR.strings.pref_text_reader_font_size),
|
label = stringResource(MR.strings.pref_text_reader_font_size),
|
||||||
value = fontSize,
|
value = fontSize,
|
||||||
valueRange = 32..72,
|
valueRange = 10..30,
|
||||||
steps = 19,
|
steps = 19,
|
||||||
valueString = fontSize.toString(),
|
valueString = "$fontSize sp",
|
||||||
onChange = fontSizePref::set,
|
onChange = { value ->
|
||||||
|
fontSizePref.set(value)
|
||||||
|
viewModel.requestTextSettingsReload()
|
||||||
|
},
|
||||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,8 +48,11 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
|
|||||||
value = lineSpacing,
|
value = lineSpacing,
|
||||||
valueRange = 90..180,
|
valueRange = 90..180,
|
||||||
steps = 17,
|
steps = 17,
|
||||||
valueString = "$lineSpacing%",
|
valueString = String.format("%.2fx", lineSpacing / 100f),
|
||||||
onChange = lineSpacingPref::set,
|
onChange = { value ->
|
||||||
|
lineSpacingPref.set(value)
|
||||||
|
viewModel.requestTextSettingsReload()
|
||||||
|
},
|
||||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -55,10 +61,13 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
|
|||||||
SliderItem(
|
SliderItem(
|
||||||
label = stringResource(MR.strings.pref_text_reader_paragraph_spacing),
|
label = stringResource(MR.strings.pref_text_reader_paragraph_spacing),
|
||||||
value = paragraphSpacing,
|
value = paragraphSpacing,
|
||||||
valueRange = 0..48,
|
valueRange = 0..16,
|
||||||
steps = 11,
|
steps = 15,
|
||||||
valueString = paragraphSpacing.toString(),
|
valueString = "$paragraphSpacing dp",
|
||||||
onChange = paragraphSpacingPref::set,
|
onChange = { value ->
|
||||||
|
paragraphSpacingPref.set(value)
|
||||||
|
viewModel.requestTextSettingsReload()
|
||||||
|
},
|
||||||
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
pillColor = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,7 +77,10 @@ internal fun ColumnScope.TextSettingsPage(viewModel: ReaderSettingsViewModel) {
|
|||||||
fontFamilies.forEach { (label, value) ->
|
fontFamilies.forEach { (label, value) ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = fontFamily == value,
|
selected = fontFamily == value,
|
||||||
onClick = { fontFamilyPref.set(value) },
|
onClick = {
|
||||||
|
fontFamilyPref.set(value)
|
||||||
|
viewModel.requestTextSettingsReload()
|
||||||
|
},
|
||||||
label = { Text(stringResource(label)) },
|
label = { Text(stringResource(label)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package eu.kanade.tachiyomi.source.kavita
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.Source
|
||||||
|
|
||||||
|
private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita."
|
||||||
|
|
||||||
|
internal fun Source.isKavitaSource(): Boolean =
|
||||||
|
this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility
|
||||||
|
* with older/alternate forms used by reading lists and previous releases.
|
||||||
|
*/
|
||||||
|
internal fun String.toKavitaChapterId(): Int? {
|
||||||
|
Regex("""(?:^|/)Chapter/(\d+)""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
Regex("""(?:^|/)chapter_(\d+)""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
Regex("""[?&]chapterId=(\d+)""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(this)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.toIntOrNull()
|
||||||
|
?.let { return it }
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -223,6 +223,16 @@ class ReaderActivity : BaseActivity() {
|
|||||||
ReaderViewModel.Event.ReloadViewerChapters -> {
|
ReaderViewModel.Event.ReloadViewerChapters -> {
|
||||||
viewModel.state.value.viewerChapters?.let(::setChapters)
|
viewModel.state.value.viewerChapters?.let(::setChapters)
|
||||||
}
|
}
|
||||||
|
ReaderViewModel.Event.RefreshCurrentTextPage -> {
|
||||||
|
viewModel.state.value.viewer?.refreshCurrentPage()
|
||||||
|
}
|
||||||
|
ReaderViewModel.Event.ReloadTextViewerChapters -> {
|
||||||
|
val state = viewModel.state.value
|
||||||
|
val chapters = state.viewerChapters
|
||||||
|
if (chapters != null) {
|
||||||
|
state.viewer?.refreshTextChapters(chapters)
|
||||||
|
}
|
||||||
|
}
|
||||||
ReaderViewModel.Event.PageChanged -> {
|
ReaderViewModel.Event.PageChanged -> {
|
||||||
displayRefreshHost.flash()
|
displayRefreshHost.flash()
|
||||||
}
|
}
|
||||||
@@ -254,7 +264,8 @@ class ReaderActivity : BaseActivity() {
|
|||||||
readerState = viewModel.state,
|
readerState = viewModel.state,
|
||||||
onChangeReadingMode = viewModel::setMangaReadingMode,
|
onChangeReadingMode = viewModel::setMangaReadingMode,
|
||||||
onChangeOrientation = viewModel::setMangaOrientationType,
|
onChangeOrientation = viewModel::setMangaOrientationType,
|
||||||
onTextSettingsChanged = viewModel::reloadTextReaderStyle,
|
onTextSettingsPreview = viewModel::previewTextReaderStyle,
|
||||||
|
onTextSettingsCommit = viewModel::commitTextReaderStyle,
|
||||||
preferences = readerPreferences,
|
preferences = readerPreferences,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import eu.kanade.tachiyomi.source.model.Page
|
|||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
|
import eu.kanade.tachiyomi.ui.reader.loader.ChapterLoader
|
||||||
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
|
import eu.kanade.tachiyomi.ui.reader.loader.DownloadPageLoader
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.loader.TextReaderPage
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
import eu.kanade.tachiyomi.ui.reader.model.InsertPage
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
@@ -56,6 +57,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
|
|||||||
import eu.kanade.tachiyomi.util.storage.cacheImageDir
|
import eu.kanade.tachiyomi.util.storage.cacheImageDir
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -823,9 +825,21 @@ class ReaderViewModel(
|
|||||||
mutableState.update { it.copy(dialog = null) }
|
mutableState.update { it.copy(dialog = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reloadTextReaderStyle() {
|
fun previewTextReaderStyle() {
|
||||||
val chapter = state.value.currentChapter ?: return
|
val chapter = state.value.currentChapter ?: return
|
||||||
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
||||||
|
val style = readerPreferences.textReaderStyle(context)
|
||||||
|
val textPages = chapter.pages?.filterIsInstance<TextReaderPage>().orEmpty()
|
||||||
|
if (textPages.isEmpty()) return
|
||||||
|
|
||||||
|
textPages.forEach { it.updateStyle(style) }
|
||||||
|
eventChannel.trySend(Event.RefreshCurrentTextPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun commitTextReaderStyle() {
|
||||||
|
val chapter = state.value.currentChapter ?: return
|
||||||
|
if (chapter.pageLoader?.usesTextReaderStyle != true) return
|
||||||
|
if (chapter.pages?.any { it is TextReaderPage } != true) return
|
||||||
val chapterLoader = loader ?: return
|
val chapterLoader = loader ?: return
|
||||||
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
|
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
|
||||||
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
|
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
|
||||||
@@ -833,17 +847,14 @@ class ReaderViewModel(
|
|||||||
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
try {
|
try {
|
||||||
chapter.pageLoader?.recycle()
|
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
|
||||||
chapter.pageLoader = null
|
eventChannel.send(Event.ReloadTextViewerChapters)
|
||||||
chapter.state = ReaderChapter.State.Wait
|
// Give old visible holders time to detach before closing resources such as EpubReader.
|
||||||
chapter.requestedPage = requestedPage
|
delay(2000)
|
||||||
chapterLoader.loadChapter(chapter)
|
oldLoader.recycle()
|
||||||
val newLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
|
|
||||||
chapter.requestedPage = (readingProgress * newLastIndex).roundToInt().coerceIn(0, newLastIndex)
|
|
||||||
eventChannel.send(Event.ReloadViewerChapters)
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
if (e is CancellationException) throw e
|
if (e is CancellationException) throw e
|
||||||
logcat(LogPriority.ERROR, e) { "Failed to reload text reader style" }
|
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1048,8 @@ class ReaderViewModel(
|
|||||||
|
|
||||||
sealed interface Event {
|
sealed interface Event {
|
||||||
data object ReloadViewerChapters : Event
|
data object ReloadViewerChapters : Event
|
||||||
|
data object RefreshCurrentTextPage : Event
|
||||||
|
data object ReloadTextViewerChapters : Event
|
||||||
data object PageChanged : Event
|
data object PageChanged : Event
|
||||||
data class SetOrientation(val orientation: Int) : Event
|
data class SetOrientation(val orientation: Int) : Event
|
||||||
data class SetCoverResult(val result: SetAsCoverResult) : Event
|
data class SetCoverResult(val result: SetAsCoverResult) : Event
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ internal class BookOasisFilePageLoader(
|
|||||||
val uniFile = UniFile.fromFile(file)
|
val uniFile = UniFile.fromFile(file)
|
||||||
?: error("Unable to open cached BookOasis file: ${file.absolutePath}")
|
?: error("Unable to open cached BookOasis file: ${file.absolutePath}")
|
||||||
val loader = when (ref.format) {
|
val loader = when (ref.format) {
|
||||||
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle)
|
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
|
||||||
"pdf" -> PdfPageLoader(context, uniFile)
|
"pdf" -> PdfPageLoader(context, uniFile)
|
||||||
"txt" -> TextPageLoader(uniFile, textStyle)
|
"txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
|
||||||
else -> error("Unsupported BookOasis document format: ${ref.format}")
|
else -> error("Unsupported BookOasis document format: ${ref.format}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import eu.kanade.tachiyomi.data.download.DownloadProvider
|
|||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
|
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
|
||||||
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
|
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
|
||||||
|
import eu.kanade.tachiyomi.source.kavita.isKavitaSource
|
||||||
|
import eu.kanade.tachiyomi.source.kavita.toKavitaChapterId
|
||||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
|
||||||
@@ -20,6 +22,7 @@ import tachiyomi.domain.source.model.StubSource
|
|||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.source.local.LocalSource
|
import tachiyomi.source.local.LocalSource
|
||||||
import tachiyomi.source.local.io.Format
|
import tachiyomi.source.local.io.Format
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader used to retrieve the [PageLoader] for a given chapter.
|
* Loader used to retrieve the [PageLoader] for a given chapter.
|
||||||
@@ -71,6 +74,36 @@ class ChapterLoader(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun reloadTextChapter(chapter: ReaderChapter, readingProgress: Float): PageLoader? {
|
||||||
|
return withIOContext {
|
||||||
|
val newLoader = getPageLoader(chapter)
|
||||||
|
if (!newLoader.usesTextReaderStyle) {
|
||||||
|
newLoader.recycle()
|
||||||
|
return@withIOContext null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pages = newLoader.getPages()
|
||||||
|
.onEach { it.chapter = chapter }
|
||||||
|
if (pages.isEmpty()) {
|
||||||
|
throw Exception(context.stringResource(MR.strings.page_list_empty_error))
|
||||||
|
}
|
||||||
|
|
||||||
|
val oldLoader = chapter.pageLoader
|
||||||
|
chapter.pageLoader = newLoader
|
||||||
|
chapter.requestedPage = (readingProgress * pages.lastIndex)
|
||||||
|
.roundToInt()
|
||||||
|
.coerceIn(0, pages.lastIndex)
|
||||||
|
chapter.state = ReaderChapter.State.Loaded(pages)
|
||||||
|
oldLoader
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
newLoader.recycle()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks [chapter] to be loaded based on present pages and loader in addition to state.
|
* Checks [chapter] to be loaded based on present pages and loader in addition to state.
|
||||||
*/
|
*/
|
||||||
@@ -91,6 +124,7 @@ class ChapterLoader(
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
|
val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
|
||||||
|
val kavitaChapterId = dbChapter.url.toKavitaChapterId()
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
isDownloaded -> DownloadPageLoader(
|
isDownloaded -> DownloadPageLoader(
|
||||||
@@ -104,13 +138,23 @@ class ChapterLoader(
|
|||||||
when (format) {
|
when (format) {
|
||||||
is Format.Directory -> DirectoryPageLoader(format.file)
|
is Format.Directory -> DirectoryPageLoader(format.file)
|
||||||
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
|
is Format.Archive -> ArchivePageLoader(format.file.archiveReader(context))
|
||||||
is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle())
|
is Format.Epub -> EpubPageLoader(format.file.epubReader(context), readerPreferences.textReaderStyle(context), context.cacheDir)
|
||||||
is Format.Pdf -> PdfPageLoader(context, format.file)
|
is Format.Pdf -> PdfPageLoader(context, format.file)
|
||||||
is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle())
|
is Format.Text -> TextPageLoader(format.file, readerPreferences.textReaderStyle(context), context.cacheDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> {
|
source is HttpSource && source.isBookOasisSource() && bookOasisRef != null -> {
|
||||||
BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle())
|
BookOasisFilePageLoader(context, source, bookOasisRef, readerPreferences.textReaderStyle(context))
|
||||||
|
}
|
||||||
|
source is HttpSource && source.isKavitaSource() && kavitaChapterId != null -> {
|
||||||
|
KavitaCompatPageLoader(
|
||||||
|
context = context,
|
||||||
|
chapter = chapter,
|
||||||
|
source = source,
|
||||||
|
chapterCache = chapterCache,
|
||||||
|
chapterId = kavitaChapterId,
|
||||||
|
textStyle = readerPreferences.textReaderStyle(context),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
source is HttpSource -> HttpPageLoader(chapter, source, chapterCache)
|
source is HttpSource -> HttpPageLoader(chapter, source, chapterCache)
|
||||||
source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString()))
|
source is StubSource -> error(context.stringResource(MR.strings.source_not_installed, source.toString()))
|
||||||
|
|||||||
@@ -4,48 +4,144 @@ import eu.kanade.tachiyomi.source.model.Page
|
|||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
import mihon.core.archive.EpubReader
|
import mihon.core.archive.EpubReader
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.StringReader
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loader used to load a chapter from a .epub file.
|
* Loader used to load a chapter from a .epub file.
|
||||||
*
|
*
|
||||||
* Text-based EPUBs are rendered as regular Mihon pages. Image-only EPUBs
|
* Text-based EPUBs are rendered as regular Mihon pages. EPUB spine documents
|
||||||
* keep the original image extraction behavior.
|
* are processed one at a time so the full book text is never retained in heap.
|
||||||
|
* Image-only EPUBs keep the original image extraction behavior.
|
||||||
*/
|
*/
|
||||||
internal class EpubPageLoader(
|
internal class EpubPageLoader(
|
||||||
private val reader: EpubReader,
|
private val reader: EpubReader,
|
||||||
private val style: TextReaderStyle,
|
private val style: TextReaderStyle,
|
||||||
|
private val cacheDir: File,
|
||||||
) : PageLoader() {
|
) : PageLoader() {
|
||||||
|
|
||||||
override var isLocal: Boolean = true
|
override var isLocal: Boolean = true
|
||||||
override val usesTextReaderStyle: Boolean = true
|
override val usesTextReaderStyle: Boolean = true
|
||||||
|
|
||||||
|
private var pageStore: TextPageStore? = null
|
||||||
|
|
||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
val textPages = reader.getTextFromPages()
|
check(!isRecycled)
|
||||||
.filter { it.isNotBlank() }
|
|
||||||
.flatMap { TextPageRenderer.paginate(it, style) }
|
|
||||||
|
|
||||||
|
pageStore?.close()
|
||||||
|
pageStore = null
|
||||||
|
|
||||||
|
val store = TextPageStore(cacheDir)
|
||||||
|
return try {
|
||||||
|
val textPages = buildTextPages(store)
|
||||||
if (textPages.isNotEmpty()) {
|
if (textPages.isNotEmpty()) {
|
||||||
return textPages.mapIndexed { index, text ->
|
store.finishWriting()
|
||||||
ReaderPage(index).apply {
|
pageStore = store
|
||||||
stream = { TextPageRenderer.render(text, style) }
|
textPages
|
||||||
status = Page.State.Ready
|
} else {
|
||||||
}
|
store.close()
|
||||||
}
|
reader.getImagesFromPages().mapIndexed { index, path ->
|
||||||
}
|
|
||||||
|
|
||||||
return reader.getImagesFromPages().mapIndexed { index, path ->
|
|
||||||
ReaderPage(index).apply {
|
ReaderPage(index).apply {
|
||||||
stream = { reader.getInputStream(path)!! }
|
stream = { reader.getInputStream(path)!! }
|
||||||
status = Page.State.Ready
|
status = Page.State.Ready
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
store.close()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTextPages(store: TextPageStore): MutableList<ReaderPage> {
|
||||||
|
val packageHref = reader.getPackageHref()
|
||||||
|
val packageDocument = reader.getPackageDocument(packageHref)
|
||||||
|
val manifest = packageDocument.select("manifest > item")
|
||||||
|
.filter { item ->
|
||||||
|
val mediaType = item.attr("media-type")
|
||||||
|
mediaType == "application/xhtml+xml" || mediaType == "text/html"
|
||||||
|
}
|
||||||
|
.associateBy { it.attr("id") }
|
||||||
|
val spine = packageDocument.select("spine > itemref")
|
||||||
|
.map { it.attr("idref") }
|
||||||
|
|
||||||
|
val result = mutableListOf<ReaderPage>()
|
||||||
|
spine.forEach { id ->
|
||||||
|
val href = manifest[id]?.attr("href").orEmpty()
|
||||||
|
if (href.isBlank()) return@forEach
|
||||||
|
|
||||||
|
openEntry(packageHref, href)?.use { input ->
|
||||||
|
val text = extractText(input)
|
||||||
|
if (text.isBlank()) return@use
|
||||||
|
|
||||||
|
StringReader(text).use { textReader ->
|
||||||
|
TextPageRenderer.paginate(textReader, style) { pageText ->
|
||||||
|
val ref = store.append(pageText)
|
||||||
|
result += TextReaderPage(
|
||||||
|
index = result.size,
|
||||||
|
pageTextProvider = { store.read(ref) },
|
||||||
|
initialStyle = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractText(input: InputStream): String {
|
||||||
|
val pageDoc = Jsoup.parse(input, null, "")
|
||||||
|
pageDoc.select("br").append("\n")
|
||||||
|
pageDoc.select("p,div,li,h1,h2,h3,h4,h5,h6,blockquote").append("\n")
|
||||||
|
return pageDoc.body().wholeText()
|
||||||
|
?.replace(Regex("[ \\t]+\\n"), "\n")
|
||||||
|
?.replace(Regex("\\n{3,}"), "\n\n")
|
||||||
|
?.trim()
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openEntry(packageHref: String, href: String): InputStream? {
|
||||||
|
val resolved = resolveEntryPath(packageHref, href)
|
||||||
|
val candidates = linkedSetOf(
|
||||||
|
resolved,
|
||||||
|
resolved.replace('\\', '/'),
|
||||||
|
resolved.replace('/', '\\'),
|
||||||
|
)
|
||||||
|
return candidates.firstNotNullOfOrNull { path ->
|
||||||
|
reader.getInputStream(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveEntryPath(packageHref: String, href: String): String {
|
||||||
|
val relative = href
|
||||||
|
.substringBefore('#')
|
||||||
|
.substringBefore('?')
|
||||||
|
.trimStart('/', '\\')
|
||||||
|
val separator = if ('\\' in packageHref) '\\' else '/'
|
||||||
|
val base = packageHref.substringBeforeLast(separator, "")
|
||||||
|
val combined = if (base.isBlank()) relative else "$base$separator$relative"
|
||||||
|
val parts = combined.split('/', '\\')
|
||||||
|
val normalized = ArrayDeque<String>()
|
||||||
|
|
||||||
|
parts.forEach { part ->
|
||||||
|
when (part) {
|
||||||
|
"", "." -> Unit
|
||||||
|
".." -> if (normalized.isNotEmpty()) normalized.removeLast()
|
||||||
|
else -> normalized.addLast(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized.joinToString(separator.toString())
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun loadPage(page: ReaderPage) {
|
override suspend fun loadPage(page: ReaderPage) {
|
||||||
check(!isRecycled)
|
check(!isRecycled)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun recycle() {
|
override fun recycle() {
|
||||||
|
pageStore?.close()
|
||||||
|
pageStore = null
|
||||||
super.recycle()
|
super.recycle()
|
||||||
reader.close()
|
reader.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.hippo.unifile.UniFile
|
||||||
|
import eu.kanade.tachiyomi.data.cache.ChapterCache
|
||||||
|
import eu.kanade.tachiyomi.network.await
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
|
import mihon.core.archive.epubReader
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compatibility loader for Kavita document chapters.
|
||||||
|
*
|
||||||
|
* The Kavita extension renders every chapter through /Reader/image. That works
|
||||||
|
* for image/archive content, but EPUB/TXT can fail while Kavita is preparing an
|
||||||
|
* image cache. For document files, download the original chapter and use
|
||||||
|
* Mihon's native document reader instead. Other formats keep the extension's
|
||||||
|
* normal HttpPageLoader behavior.
|
||||||
|
*/
|
||||||
|
internal class KavitaCompatPageLoader(
|
||||||
|
private val context: Context,
|
||||||
|
private val chapter: ReaderChapter,
|
||||||
|
private val source: HttpSource,
|
||||||
|
private val chapterCache: ChapterCache,
|
||||||
|
private val chapterId: Int,
|
||||||
|
private val textStyle: TextReaderStyle,
|
||||||
|
) : PageLoader() {
|
||||||
|
|
||||||
|
override var isLocal: Boolean = false
|
||||||
|
override val usesTextReaderStyle: Boolean
|
||||||
|
get() = delegate?.usesTextReaderStyle == true
|
||||||
|
|
||||||
|
private var delegate: PageLoader? = null
|
||||||
|
|
||||||
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
|
check(!isRecycled)
|
||||||
|
|
||||||
|
val format = detectDocumentFormat()
|
||||||
|
val loader = when (format) {
|
||||||
|
"epub", "txt" -> createDocumentLoader(format)
|
||||||
|
else -> HttpPageLoader(chapter, source, chapterCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
delegate = loader
|
||||||
|
isLocal = loader.isLocal
|
||||||
|
return loader.getPages()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadPage(page: ReaderPage) {
|
||||||
|
delegate?.loadPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun retryPage(page: ReaderPage) {
|
||||||
|
delegate?.retryPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recycle() {
|
||||||
|
delegate?.recycle()
|
||||||
|
delegate = null
|
||||||
|
super.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun detectDocumentFormat(): String? {
|
||||||
|
val apiBase = source.baseUrl.trimEnd('/') + "/api"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$apiBase/Chapter?chapterId=$chapterId")
|
||||||
|
.headers(source.headers)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
source.client.newCall(request).await().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
// Preserve the extension's normal behavior when chapter metadata
|
||||||
|
// cannot be queried. It may still be readable as image content.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val body = response.body.string()
|
||||||
|
if (body.isBlank()) return null
|
||||||
|
|
||||||
|
val chapterJson = JSONObject(body)
|
||||||
|
val files = chapterJson.optJSONArray("files") ?: return null
|
||||||
|
for (i in 0 until files.length()) {
|
||||||
|
val file = files.optJSONObject(i) ?: continue
|
||||||
|
val extension = normalizeExtension(
|
||||||
|
file.optString("extension").ifBlank { file.optString("filePath") },
|
||||||
|
)
|
||||||
|
if (extension == "epub" || extension == "txt") {
|
||||||
|
return extension
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current Kavita format enum: EPUB == 3. TXT has no dedicated
|
||||||
|
// enum value in upstream Kavita, so it is detected by extension.
|
||||||
|
if (file.optInt("format", -1) == 3) {
|
||||||
|
return "epub"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeExtension(value: String): String =
|
||||||
|
value.substringAfterLast('.', value)
|
||||||
|
.trim()
|
||||||
|
.trimStart('.')
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
|
||||||
|
private suspend fun createDocumentLoader(format: String): PageLoader {
|
||||||
|
val file = getOrDownloadDocument(format)
|
||||||
|
val uniFile = UniFile.fromFile(file)
|
||||||
|
?: throw IOException("Unable to open cached Kavita ${format.uppercase()} file")
|
||||||
|
|
||||||
|
return when (format) {
|
||||||
|
"epub" -> EpubPageLoader(uniFile.epubReader(context), textStyle, context.cacheDir)
|
||||||
|
"txt" -> TextPageLoader(uniFile, textStyle, context.cacheDir)
|
||||||
|
else -> error("Unsupported Kavita document format: $format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getOrDownloadDocument(format: String): File {
|
||||||
|
val cacheDir = File(context.cacheDir, "kavita_reader").apply { mkdirs() }
|
||||||
|
val target = File(cacheDir, "${source.id}-$chapterId.$format")
|
||||||
|
if (target.isFile && target.length() > 0L) return target
|
||||||
|
|
||||||
|
val temp = File(cacheDir, "${target.name}.part")
|
||||||
|
if (temp.exists()) temp.delete()
|
||||||
|
|
||||||
|
val apiBase = source.baseUrl.trimEnd('/') + "/api"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$apiBase/Download/chapter?chapterId=$chapterId")
|
||||||
|
.headers(source.headers)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
source.client.newCall(request).await().use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val message = when (response.code) {
|
||||||
|
403 -> "Kavita account does not have Download permission"
|
||||||
|
404 -> "Kavita original file is missing or no longer available"
|
||||||
|
500 -> "Kavita could not access the original $format file"
|
||||||
|
else -> "Kavita document download failed: HTTP ${response.code}"
|
||||||
|
}
|
||||||
|
throw IOException(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
temp.outputStream().use { output ->
|
||||||
|
response.body.byteStream().use { input -> input.copyTo(output) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temp.length() <= 0L) {
|
||||||
|
temp.delete()
|
||||||
|
throw IOException("Kavita returned an empty ${format.uppercase()} file")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!temp.renameTo(target)) {
|
||||||
|
temp.copyTo(target, overwrite = true)
|
||||||
|
temp.delete()
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader.loader
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
import com.hippo.unifile.UniFile
|
import com.hippo.unifile.UniFile
|
||||||
import eu.kanade.tachiyomi.source.model.Page
|
|
||||||
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
import java.nio.ByteBuffer
|
import java.io.File
|
||||||
import java.nio.charset.CharacterCodingException
|
import java.io.InputStreamReader
|
||||||
|
import java.io.Reader
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.nio.charset.CodingErrorAction
|
import java.nio.charset.CodingErrorAction
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
@@ -13,39 +13,94 @@ import java.nio.charset.StandardCharsets
|
|||||||
internal class TextPageLoader(
|
internal class TextPageLoader(
|
||||||
private val file: UniFile,
|
private val file: UniFile,
|
||||||
private val style: TextReaderStyle,
|
private val style: TextReaderStyle,
|
||||||
|
private val cacheDir: File,
|
||||||
) : PageLoader() {
|
) : PageLoader() {
|
||||||
|
|
||||||
override var isLocal: Boolean = true
|
override var isLocal: Boolean = true
|
||||||
override val usesTextReaderStyle: Boolean = true
|
override val usesTextReaderStyle: Boolean = true
|
||||||
|
|
||||||
|
private var pageStore: TextPageStore? = null
|
||||||
|
|
||||||
override suspend fun getPages(): List<ReaderPage> {
|
override suspend fun getPages(): List<ReaderPage> {
|
||||||
val bytes = file.openInputStream().use { it.readBytes() }
|
pageStore?.close()
|
||||||
val text = decodeText(bytes)
|
pageStore = null
|
||||||
return TextPageRenderer.paginate(text, style).mapIndexed { index, pageText ->
|
|
||||||
ReaderPage(index).apply {
|
|
||||||
stream = { TextPageRenderer.render(pageText, style) }
|
|
||||||
status = Page.State.Ready
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decodeText(bytes: ByteArray): String {
|
|
||||||
if (bytes.size >= 3 &&
|
|
||||||
bytes[0] == 0xEF.toByte() &&
|
|
||||||
bytes[1] == 0xBB.toByte() &&
|
|
||||||
bytes[2] == 0xBF.toByte()
|
|
||||||
) {
|
|
||||||
return String(bytes, 3, bytes.size - 3, StandardCharsets.UTF_8)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
val store = TextPageStore(cacheDir)
|
||||||
return try {
|
return try {
|
||||||
StandardCharsets.UTF_8.newDecoder()
|
val pages = mutableListOf<ReaderPage>()
|
||||||
|
openDecodedReader().use { reader ->
|
||||||
|
TextPageRenderer.paginate(reader, style) { pageText ->
|
||||||
|
val ref = store.append(pageText)
|
||||||
|
pages += TextReaderPage(
|
||||||
|
index = pages.size,
|
||||||
|
pageTextProvider = { store.read(ref) },
|
||||||
|
initialStyle = style,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store.finishWriting()
|
||||||
|
pageStore = store
|
||||||
|
pages
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
store.close()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun recycle() {
|
||||||
|
pageStore?.close()
|
||||||
|
pageStore = null
|
||||||
|
super.recycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openDecodedReader(): Reader {
|
||||||
|
val charset = detectCharset()
|
||||||
|
return InputStreamReader(file.openInputStream().buffered(), charset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectCharset(): Charset {
|
||||||
|
val prefix = ByteArray(3)
|
||||||
|
val prefixSize = file.openInputStream().buffered().use { input ->
|
||||||
|
input.read(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
prefixSize >= 3 &&
|
||||||
|
prefix[0] == 0xEF.toByte() &&
|
||||||
|
prefix[1] == 0xBB.toByte() &&
|
||||||
|
prefix[2] == 0xBF.toByte()
|
||||||
|
) {
|
||||||
|
return StandardCharsets.UTF_8
|
||||||
|
}
|
||||||
|
if (prefixSize >= 2 && prefix[0] == 0xFF.toByte() && prefix[1] == 0xFE.toByte()) {
|
||||||
|
return StandardCharsets.UTF_16LE
|
||||||
|
}
|
||||||
|
if (prefixSize >= 2 && prefix[0] == 0xFE.toByte() && prefix[1] == 0xFF.toByte()) {
|
||||||
|
return StandardCharsets.UTF_16BE
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (isValidUtf8()) {
|
||||||
|
StandardCharsets.UTF_8
|
||||||
|
} else {
|
||||||
|
Charset.forName("MS949")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidUtf8(): Boolean {
|
||||||
|
val decoder = StandardCharsets.UTF_8.newDecoder()
|
||||||
.onMalformedInput(CodingErrorAction.REPORT)
|
.onMalformedInput(CodingErrorAction.REPORT)
|
||||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||||
.decode(ByteBuffer.wrap(bytes))
|
|
||||||
.toString()
|
return try {
|
||||||
} catch (_: CharacterCodingException) {
|
InputStreamReader(file.openInputStream().buffered(), decoder).use { reader ->
|
||||||
String(bytes, Charset.forName("MS949"))
|
val buffer = CharArray(8 * 1024)
|
||||||
|
while (reader.read(buffer) >= 0) {
|
||||||
|
// Validation only. Decoded characters are intentionally discarded.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
|||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
import java.io.Reader
|
||||||
|
|
||||||
internal object TextPageRenderer {
|
internal object TextPageRenderer {
|
||||||
private const val PAGE_WIDTH = 1440
|
private const val PAGE_WIDTH = 1440
|
||||||
@@ -22,14 +23,88 @@ internal object TextPageRenderer {
|
|||||||
private const val MARGIN = 96
|
private const val MARGIN = 96
|
||||||
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
|
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
|
||||||
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
|
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
|
||||||
|
private const val STREAM_BUFFER_CHARS = 64 * 1024
|
||||||
|
private const val STREAM_READ_CHARS = 8 * 1024
|
||||||
|
|
||||||
fun paginate(text: String, style: TextReaderStyle): List<String> {
|
fun paginate(text: String, style: TextReaderStyle): List<String> {
|
||||||
if (text.isBlank()) return emptyList()
|
if (text.isBlank()) return emptyList()
|
||||||
|
|
||||||
val layout = createLayout(text, style)
|
return calculatePageSlices(text, style)
|
||||||
if (layout.lineCount == 0) return listOf(text)
|
.mapNotNull { slice ->
|
||||||
|
text.substring(slice.start, slice.endExclusive)
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
.ifEmpty { listOf(text) }
|
||||||
|
}
|
||||||
|
|
||||||
val pages = mutableListOf<String>()
|
/**
|
||||||
|
* Incrementally paginates a text stream while keeping only a bounded text
|
||||||
|
* window in memory. The final incomplete page is retained between windows
|
||||||
|
* so normal page filling is preserved across chunk boundaries.
|
||||||
|
*/
|
||||||
|
fun paginate(
|
||||||
|
reader: Reader,
|
||||||
|
style: TextReaderStyle,
|
||||||
|
onPage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val working = StringBuilder(STREAM_BUFFER_CHARS + STREAM_READ_CHARS)
|
||||||
|
val chars = CharArray(STREAM_READ_CHARS)
|
||||||
|
var firstChunk = true
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val read = reader.read(chars)
|
||||||
|
if (read < 0) break
|
||||||
|
if (read == 0) continue
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
if (firstChunk) {
|
||||||
|
firstChunk = false
|
||||||
|
if (chars[0] == '\uFEFF') {
|
||||||
|
offset = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (offset < read) {
|
||||||
|
working.append(chars, offset, read - offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (working.length >= STREAM_BUFFER_CHARS) {
|
||||||
|
emitCompletePages(working, style, onPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (working.isNotEmpty()) {
|
||||||
|
paginate(working.toString(), style).forEach(onPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitCompletePages(
|
||||||
|
working: StringBuilder,
|
||||||
|
style: TextReaderStyle,
|
||||||
|
onPage: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val text = working.toString()
|
||||||
|
val slices = calculatePageSlices(text, style)
|
||||||
|
if (slices.size <= 1) return
|
||||||
|
|
||||||
|
for (index in 0 until slices.lastIndex) {
|
||||||
|
val slice = slices[index]
|
||||||
|
text.substring(slice.start, slice.endExclusive)
|
||||||
|
.trim()
|
||||||
|
.takeIf { it.isNotEmpty() }
|
||||||
|
?.let(onPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
working.delete(0, slices.last().start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val layout = createLayout(text, style)
|
||||||
|
if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
|
||||||
|
|
||||||
|
val pages = mutableListOf<PageSlice>()
|
||||||
var startLine = 0
|
var startLine = 0
|
||||||
while (startLine < layout.lineCount) {
|
while (startLine < layout.lineCount) {
|
||||||
val startTop = layout.getLineTop(startLine)
|
val startTop = layout.getLineTop(startLine)
|
||||||
@@ -43,16 +118,17 @@ internal object TextPageRenderer {
|
|||||||
|
|
||||||
val start = layout.getLineStart(startLine)
|
val start = layout.getLineStart(startLine)
|
||||||
val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length)
|
val end = layout.getLineEnd(endLine).coerceAtLeast(start + 1).coerceAtMost(text.length)
|
||||||
text.substring(start, end).trim().takeIf { it.isNotEmpty() }?.let(pages::add)
|
pages += PageSlice(start, end)
|
||||||
startLine = endLine + 1
|
startLine = endLine + 1
|
||||||
}
|
}
|
||||||
return pages.ifEmpty { listOf(text) }
|
return pages.ifEmpty { listOf(PageSlice(0, text.length)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun render(text: String, style: TextReaderStyle): InputStream {
|
fun render(text: String, style: TextReaderStyle): InputStream {
|
||||||
val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
canvas.drawColor(Color.WHITE)
|
val (backgroundColor, _) = resolveColors(style)
|
||||||
|
canvas.drawColor(backgroundColor)
|
||||||
canvas.save()
|
canvas.save()
|
||||||
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
|
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
|
||||||
createLayout(text, style).draw(canvas)
|
createLayout(text, style).draw(canvas)
|
||||||
@@ -65,15 +141,16 @@ internal object TextPageRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
|
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
|
||||||
|
val (_, textColor) = resolveColors(style)
|
||||||
val paint = TextPaint().apply {
|
val paint = TextPaint().apply {
|
||||||
isAntiAlias = true
|
isAntiAlias = true
|
||||||
color = Color.BLACK
|
color = textColor
|
||||||
textSize = style.fontSize.toFloat()
|
textSize = style.fontSize * 3f
|
||||||
typeface = style.fontFamily.androidFamilyName
|
typeface = style.fontFamily.androidFamilyName
|
||||||
?.let { Typeface.create(it, Typeface.NORMAL) }
|
?.let { Typeface.create(it, Typeface.NORMAL) }
|
||||||
?: Typeface.DEFAULT
|
?: Typeface.DEFAULT
|
||||||
}
|
}
|
||||||
val styledText = addParagraphSpacing(text, style.paragraphSpacing)
|
val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3)
|
||||||
return StaticLayout.Builder
|
return StaticLayout.Builder
|
||||||
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
|
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
|
||||||
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
||||||
@@ -82,6 +159,22 @@ internal object TextPageRenderer {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
|
||||||
|
val useDark = when (style.readerTheme) {
|
||||||
|
0 -> false
|
||||||
|
1 -> true
|
||||||
|
2 -> true
|
||||||
|
3 -> style.automaticDark
|
||||||
|
else -> style.automaticDark
|
||||||
|
}
|
||||||
|
val background = when (style.readerTheme) {
|
||||||
|
2 -> Color.rgb(48, 48, 48)
|
||||||
|
else -> if (useDark) Color.BLACK else Color.WHITE
|
||||||
|
}
|
||||||
|
val foreground = if (useDark) Color.WHITE else Color.BLACK
|
||||||
|
return background to foreground
|
||||||
|
}
|
||||||
|
|
||||||
private fun addParagraphSpacing(text: String, spacing: Int): CharSequence {
|
private fun addParagraphSpacing(text: String, spacing: Int): CharSequence {
|
||||||
if (spacing <= 0 || '\n' !in text) return text
|
if (spacing <= 0 || '\n' !in text) return text
|
||||||
val spannable = SpannableString(text)
|
val spannable = SpannableString(text)
|
||||||
@@ -111,4 +204,9 @@ internal object TextPageRenderer {
|
|||||||
fm.bottom += spacing
|
fm.bottom += spacing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class PageSlice(
|
||||||
|
val start: Int,
|
||||||
|
val endExclusive: Int,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disk-backed storage for text reader pages.
|
||||||
|
*
|
||||||
|
* Keeping page strings here avoids retaining an entire large TXT/EPUB in the
|
||||||
|
* Java heap. Individual pages are loaded only when the viewer renders them.
|
||||||
|
*/
|
||||||
|
internal class TextPageStore(cacheRoot: File) : Closeable {
|
||||||
|
|
||||||
|
private val cacheDir = File(cacheRoot, "text_reader_pages").apply {
|
||||||
|
mkdirs()
|
||||||
|
pruneStaleFiles()
|
||||||
|
}
|
||||||
|
private val file = File.createTempFile("pages-", ".utf8", cacheDir)
|
||||||
|
private var writer: RandomAccessFile? = RandomAccessFile(file, "rw")
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun append(text: String): PageRef {
|
||||||
|
val output = checkNotNull(writer) { "Text page store is already finalized" }
|
||||||
|
val bytes = text.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
val offset = output.filePointer
|
||||||
|
output.write(bytes)
|
||||||
|
return PageRef(offset, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun finishWriting() {
|
||||||
|
writer?.fd?.sync()
|
||||||
|
writer?.close()
|
||||||
|
writer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun read(ref: PageRef): String {
|
||||||
|
val bytes = ByteArray(ref.byteLength)
|
||||||
|
RandomAccessFile(file, "r").use { input ->
|
||||||
|
input.seek(ref.offset)
|
||||||
|
input.readFully(bytes)
|
||||||
|
}
|
||||||
|
return String(bytes, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
writer?.close()
|
||||||
|
writer = null
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class PageRef(
|
||||||
|
val offset: Long,
|
||||||
|
val byteLength: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun File.pruneStaleFiles() {
|
||||||
|
val cutoff = System.currentTimeMillis() - STALE_FILE_AGE_MS
|
||||||
|
listFiles()
|
||||||
|
?.filter { it.isFile && it.lastModified() < cutoff }
|
||||||
|
?.forEach { it.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val STALE_FILE_AGE_MS = 24L * 60L * 60L * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package eu.kanade.tachiyomi.ui.reader.loader
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
|
||||||
|
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rendered text page whose style can be changed without rebuilding the chapter.
|
||||||
|
* Page boundaries remain stable during the live preview; a full repagination is done
|
||||||
|
* once when the settings dialog is dismissed.
|
||||||
|
*/
|
||||||
|
internal class TextReaderPage(
|
||||||
|
index: Int,
|
||||||
|
private val pageTextProvider: () -> String,
|
||||||
|
initialStyle: TextReaderStyle,
|
||||||
|
) : ReaderPage(index) {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var style: TextReaderStyle = initialStyle
|
||||||
|
|
||||||
|
init {
|
||||||
|
stream = { TextPageRenderer.render(pageTextProvider(), style) }
|
||||||
|
status = Page.State.Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
index: Int,
|
||||||
|
pageText: String,
|
||||||
|
initialStyle: TextReaderStyle,
|
||||||
|
) : this(index, { pageText }, initialStyle)
|
||||||
|
|
||||||
|
fun updateStyle(newStyle: TextReaderStyle) {
|
||||||
|
style = newStyle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package eu.kanade.tachiyomi.ui.reader.setting
|
package eu.kanade.tachiyomi.ui.reader.setting
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
@@ -11,6 +13,7 @@ import tachiyomi.core.common.preference.PreferenceStore
|
|||||||
import tachiyomi.core.common.preference.getEnum
|
import tachiyomi.core.common.preference.getEnum
|
||||||
import tachiyomi.core.common.preference.getEnumSet
|
import tachiyomi.core.common.preference.getEnumSet
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
@@ -110,22 +113,38 @@ class ReaderPreferences(
|
|||||||
|
|
||||||
// region Text reader
|
// region Text reader
|
||||||
|
|
||||||
val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 44)
|
val textFontSize: Preference<Int> = preferenceStore.getInt("reader_text_font_size", 16)
|
||||||
|
|
||||||
val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115)
|
val textLineSpacing: Preference<Int> = preferenceStore.getInt("reader_text_line_spacing", 115)
|
||||||
|
|
||||||
val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 12)
|
val textParagraphSpacing: Preference<Int> = preferenceStore.getInt("reader_text_paragraph_spacing", 4)
|
||||||
|
|
||||||
val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum(
|
val textFontFamily: Preference<TextFontFamily> = preferenceStore.getEnum(
|
||||||
"reader_text_font_family",
|
"reader_text_font_family",
|
||||||
TextFontFamily.SYSTEM,
|
TextFontFamily.SYSTEM,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun textReaderStyle() = TextReaderStyle(
|
private val textUnitsMigrated = preferenceStore.getBoolean("reader_text_units_v2_migrated", false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (!textUnitsMigrated.get()) {
|
||||||
|
val legacyFontSize = textFontSize.get()
|
||||||
|
if (legacyFontSize > 30) {
|
||||||
|
textFontSize.set((legacyFontSize / 3f).roundToInt().coerceIn(10, 30))
|
||||||
|
textParagraphSpacing.set((textParagraphSpacing.get() / 3f).roundToInt().coerceIn(0, 16))
|
||||||
|
}
|
||||||
|
textUnitsMigrated.set(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun textReaderStyle(context: Context) = TextReaderStyle(
|
||||||
fontSize = textFontSize.get(),
|
fontSize = textFontSize.get(),
|
||||||
lineSpacingPercent = textLineSpacing.get(),
|
lineSpacingPercent = textLineSpacing.get(),
|
||||||
paragraphSpacing = textParagraphSpacing.get(),
|
paragraphSpacing = textParagraphSpacing.get(),
|
||||||
fontFamily = textFontFamily.get(),
|
fontFamily = textFontFamily.get(),
|
||||||
|
readerTheme = readerTheme.get(),
|
||||||
|
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
Configuration.UI_MODE_NIGHT_YES,
|
||||||
)
|
)
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
@@ -378,4 +397,6 @@ data class TextReaderStyle(
|
|||||||
val lineSpacingPercent: Int,
|
val lineSpacingPercent: Int,
|
||||||
val paragraphSpacing: Int,
|
val paragraphSpacing: Int,
|
||||||
val fontFamily: ReaderPreferences.TextFontFamily,
|
val fontFamily: ReaderPreferences.TextFontFamily,
|
||||||
|
val readerTheme: Int,
|
||||||
|
val automaticDark: Boolean,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import eu.kanade.tachiyomi.ui.reader.ReaderViewModel
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class ReaderSettingsViewModel(
|
class ReaderSettingsViewModel(
|
||||||
readerState: StateFlow<ReaderViewModel.State>,
|
readerState: StateFlow<ReaderViewModel.State>,
|
||||||
val onChangeReadingMode: (ReadingMode) -> Unit,
|
val onChangeReadingMode: (ReadingMode) -> Unit,
|
||||||
val onChangeOrientation: (ReaderOrientation) -> Unit,
|
val onChangeOrientation: (ReaderOrientation) -> Unit,
|
||||||
val onTextSettingsChanged: () -> Unit,
|
private val onTextSettingsPreview: () -> Unit,
|
||||||
|
private val onTextSettingsCommit: () -> Unit,
|
||||||
val preferences: ReaderPreferences,
|
val preferences: ReaderPreferences,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -26,4 +30,26 @@ class ReaderSettingsViewModel(
|
|||||||
.map { it.manga }
|
.map { it.manga }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
.stateIn(viewModelScope, SharingStarted.Lazily, null)
|
||||||
|
|
||||||
|
private var textSettingsReloadJob: Job? = null
|
||||||
|
private var textSettingsDirty = false
|
||||||
|
|
||||||
|
fun requestTextSettingsReload() {
|
||||||
|
textSettingsDirty = true
|
||||||
|
textSettingsReloadJob?.cancel()
|
||||||
|
textSettingsReloadJob = viewModelScope.launch {
|
||||||
|
delay(200)
|
||||||
|
onTextSettingsPreview()
|
||||||
|
textSettingsReloadJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flushTextSettingsReload() {
|
||||||
|
if (!textSettingsDirty) return
|
||||||
|
textSettingsReloadJob?.cancel()
|
||||||
|
textSettingsReloadJob = null
|
||||||
|
onTextSettingsPreview()
|
||||||
|
textSettingsDirty = false
|
||||||
|
onTextSettingsCommit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ interface Viewer {
|
|||||||
*/
|
*/
|
||||||
fun setChapters(chapters: ViewerChapters)
|
fun setChapters(chapters: ViewerChapters)
|
||||||
|
|
||||||
|
/** Rebinds only the currently visible page(s), used for lightweight text-style preview. */
|
||||||
|
fun refreshCurrentPage() {}
|
||||||
|
|
||||||
|
/** Replaces repaginated text pages without expensive per-item diffing where possible. */
|
||||||
|
fun refreshTextChapters(chapters: ViewerChapters) {
|
||||||
|
setChapters(chapters)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells this viewer to move to the given [page].
|
* Tells this viewer to move to the given [page].
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -380,6 +380,17 @@ abstract class PagerViewer(val activity: ReaderActivity) : Viewer {
|
|||||||
pager.setCurrentItem(currentItem, false)
|
pager.setCurrentItem(currentItem, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun refreshCurrentPage() {
|
||||||
|
refreshAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refreshTextChapters(chapters: ViewerChapters) {
|
||||||
|
setChaptersInternal(chapters)
|
||||||
|
val pages = chapters.currChapter.pages ?: return
|
||||||
|
val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]
|
||||||
|
moveToPage(page)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called from the containing activity when a key [event] is received. It should return true
|
* Called from the containing activity when a key [event] is received. It should return true
|
||||||
* if the event was handled, false otherwise.
|
* if the event was handled, false otherwise.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
|
|||||||
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
|
* Updates this adapter with the given [chapters]. It handles setting a few pages of the
|
||||||
* next/previous chapter to allow seamless transitions.
|
* next/previous chapter to allow seamless transitions.
|
||||||
*/
|
*/
|
||||||
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean) {
|
fun setChapters(chapters: ViewerChapters, forceTransition: Boolean, useDiff: Boolean = true) {
|
||||||
val newItems = mutableListOf<Any>()
|
val newItems = mutableListOf<Any>()
|
||||||
|
|
||||||
// Forces chapter transition if there is missing chapters
|
// Forces chapter transition if there is missing chapters
|
||||||
@@ -65,13 +65,18 @@ class WebtoonAdapter(val viewer: WebtoonViewer) : RecyclerView.Adapter<RecyclerV
|
|||||||
|
|
||||||
chapters.nextChapter?.pages?.let(newItems::addAll)
|
chapters.nextChapter?.pages?.let(newItems::addAll)
|
||||||
|
|
||||||
updateItems(newItems)
|
updateItems(newItems, useDiff)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateItems(newItems: List<Any>) {
|
private fun updateItems(newItems: List<Any>, useDiff: Boolean) {
|
||||||
|
if (useDiff) {
|
||||||
val result = DiffUtil.calculateDiff(Callback(items, newItems))
|
val result = DiffUtil.calculateDiff(Callback(items, newItems))
|
||||||
items = newItems
|
items = newItems
|
||||||
result.dispatchUpdatesTo(this)
|
result.dispatchUpdatesTo(this)
|
||||||
|
} else {
|
||||||
|
items = newItems
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@@ -353,6 +353,19 @@ class WebtoonViewer(val activity: ReaderActivity, val isContinuous: Boolean = tr
|
|||||||
min(position + 3, adapter.itemCount - 1),
|
min(position + 3, adapter.itemCount - 1),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun refreshCurrentPage() {
|
||||||
|
refreshAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun refreshTextChapters(chapters: ViewerChapters) {
|
||||||
|
val forceTransition = config.alwaysShowChapterTransition || currentPage is ChapterTransition
|
||||||
|
adapter.setChapters(chapters, forceTransition, useDiff = false)
|
||||||
|
val pages = chapters.currChapter.pages ?: return
|
||||||
|
val page = pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]
|
||||||
|
moveToPage(page)
|
||||||
|
currentPage = page
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double the cache size to reduce rebinds/recycles incurred by the extra layout space on scroll direction changes
|
// Double the cache size to reduce rebinds/recycles incurred by the extra layout space on scroll direction changes
|
||||||
|
|||||||
Reference in New Issue
Block a user