From f45d0ad31db296d5c6f38effb1d889ea70f8f139 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:14:22 +0600 Subject: [PATCH] Remove the WebGPU reader and go back to the old image decoder Brings back SSIV and the image decoder from v0.20.4 with their settings, except "Use legacy decoder for long strip reader", which long strip pages drawn as strips replace. --- CHANGELOG.md | 19 +- app/build.gradle.kts | 2 - .../eu/kanade/domain/base/BasePreferences.kt | 8 +- .../manga/components/MangaCoverDialog.kt | 70 - .../settings/screen/SettingsAdvancedScreen.kt | 41 +- .../reader/settings/ReadingModePage.kt | 184 +- app/src/main/java/eu/kanade/tachiyomi/App.kt | 10 + .../tachiyomi/data/cache/ChapterCache.kt | 21 +- .../tachiyomi/data/coil/ImageDecoder.kt | 183 +- .../eu/kanade/tachiyomi/data/coil/Utils.kt | 8 - .../tachiyomi/ui/reader/ReaderActivity.kt | 30 +- .../ui/reader/loader/HttpPageLoader.kt | 13 +- .../ui/reader/model/DownloadStream.kt | 130 -- .../tachiyomi/ui/reader/model/ReaderPage.kt | 4 - .../ui/reader/setting/ReaderPreferences.kt | 77 +- .../ui/reader/setting/ReadingMode.kt | 13 - .../ui/reader/viewer/ReaderPageImageView.kt | 3 + .../ui/reader/viewer/webgpu/WebGpuConfig.kt | 212 -- .../ui/reader/viewer/webgpu/WebGpuViewer.kt | 2064 ----------------- .../viewer/webgpu/WebGpuViewerContinuous.kt | 102 - .../eu/kanade/tachiyomi/util/system/GLUtil.kt | 60 + .../core/common/util/system/ImageUtil.kt | 208 +- gradle/libs.versions.toml | 10 +- .../moko-resources/base/strings.xml | 32 +- 24 files changed, 403 insertions(+), 3101 deletions(-) delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/DownloadStream.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt delete mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt create mode 100644 core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c876a548..b5fdaef15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,22 +12,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ## [Unreleased] ### Added -- Add experimental high quality reader renderer (Settings → Advanced → Use high quality renderer) with better image scaling, page transition animations, dual page view and display cutout handling ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3388](https://github.com/mihonapp/mihon/pull/3388)) - - Match background and transition page colors to the old reader ([@AntsyLich](https://github.com/AntsyLich)) ([#3765](https://github.com/mihonapp/mihon/pull/3765)) - - Support the Fit width, Fit height and Original size scale types and the zoom start position ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3801](https://github.com/mihonapp/mihon/pull/3801)) - - Fix preloading and navigation across chapters, and right-to-left navigation ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3823](https://github.com/mihonapp/mihon/pull/3823)) - - Add a "None" page transition and fix the background color of animated images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3843](https://github.com/mihonapp/mihon/pull/3843)) - - Improve transition and error pages, dual page animations and border cropping ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3852](https://github.com/mihonapp/mihon/pull/3852)) - - Fix navigation directions and long strip bounds, and fade in loaded pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3870](https://github.com/mihonapp/mihon/pull/3870)) - - Add a "Page flip" transition, a long strip minimum width setting and separate dual page settings, and show wide pages on their own in dual page view ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3886](https://github.com/mihonapp/mihon/pull/3886)) - - Fix freezes and running out of memory when jumping between pages with the slider ([@Danidukiyu](https://github.com/Danidukiyu)) ([#3872](https://github.com/mihonapp/mihon/pull/3872)) - - Add HDR, ICC color profile and EXIF orientation support ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933)) - - Add a page gap setting for long strip with gaps and a "Disable zoom out" setting for long strip modes ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3933](https://github.com/mihonapp/mihon/pull/3933)) - - Fix a crash on Android 15 and long strip progress and background color ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3949](https://github.com/mihonapp/mihon/pull/3949)) - - Keep the reader menu open while moving through pages with the slider ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3956](https://github.com/mihonapp/mihon/pull/3956)) - - Show pages progressively while they download ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4023](https://github.com/mihonapp/mihon/pull/4023)) - - Lower memory use for animated images and support cropping borders in long strip ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4029](https://github.com/mihonapp/mihon/pull/4029)) - - Pair more wide pages in dual page view by comparing them with neighbouring pages ([@wwww-wwww](https://github.com/wwww-wwww)) ([#4051](https://github.com/mihonapp/mihon/pull/4051)) - Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya), [@BrutuZ](https://github.com/BrutuZ)) ([#3776](https://github.com/mihonapp/mihon/pull/3776)) - Allow `id:` to search for slugs on Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3792](https://github.com/mihonapp/mihon/pull/3792)) - Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818)) @@ -45,7 +29,6 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ### Changed - Download app updates from the update screen instead of a background job, and remove the "App updates" notification channel ([@AntsyLich](https://github.com/AntsyLich)) ([#3707](https://github.com/mihonapp/mihon/pull/3707)) -- Use a new decoder for images Android can't decode itself, like AVIF, JPEG XL and HEIF, and support JPEG 2000 images ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786)) - Switch app icons to Material Symbols ([@AntsyLich](https://github.com/AntsyLich)) ([#3873](https://github.com/mihonapp/mihon/pull/3873)) - Use the filled icon for the library continue reading button ([@AntsyLich](https://github.com/AntsyLich)) ([`34a33ed`](https://github.com/mihonapp/mihon/commit/34a33ed)) - Move notification, launcher shortcut, bottom navigation and reading mode icons to Material Symbols, bring back filled icons where the switch dropped them, and show a compact grid icon for the compact display modes ([@AntsyLich](https://github.com/AntsyLich)) ([#4057](https://github.com/mihonapp/mihon/pull/4057)) @@ -102,7 +85,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Speed up queueing many chapters for download at once ([@AntsyLich](https://github.com/AntsyLich)) ([`f02bf5b`](https://github.com/mihonapp/mihon/commit/f02bf5b)) ### Removed -- Remove "Custom hardware bitmap threshold", "Use legacy decoder for long strip reader" and "Custom display profile" advanced settings ([@wwww-wwww](https://github.com/wwww-wwww)) ([#3786](https://github.com/mihonapp/mihon/pull/3786)) +- Remove "Use legacy decoder for long strip reader" advanced setting, since long strip pages no longer load as one big image ([@AntsyLich](https://github.com/AntsyLich)) ### Fixed - Fixed app and extension update check running again on configuration change ([@AntsyLich](https://github.com/AntsyLich)) ([#3708](https://github.com/mihonapp/mihon/pull/3708)) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f62808bee..324528b7b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -291,8 +291,6 @@ dependencies { } implementation(libs.image.decoder) - implementation(libs.webgpuviewer) - // UI libraries implementation(libs.material) implementation(libs.flexibleAdapter) diff --git a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt index 1ffcbf6d5..e76c97328 100644 --- a/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/base/BasePreferences.kt @@ -5,6 +5,7 @@ import dev.icerock.moko.resources.StringResource import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import eu.kanade.tachiyomi.util.system.GLUtil import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.i18n.MR @@ -37,7 +38,12 @@ class BasePreferences( PRIVATE(MR.strings.ext_installer_private, false), } - val highQualityRenderer: Preference = preferenceStore.getBoolean("pref_high_quality_renderer_key", false) + val displayProfile: Preference = preferenceStore.getString("pref_display_profile_key", "") + + val hardwareBitmapThreshold: Preference = preferenceStore.getInt( + "pref_hardware_bitmap_threshold", + GLUtil.SAFE_TEXTURE_LIMIT, + ) val installationId: Preference = preferenceStore.getString(Preference.appStateKey("installation_id"), "") diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index ff7dc4779..0686b1d9a 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,9 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -36,27 +33,16 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.core.graphics.drawable.toDrawable import androidx.core.view.updatePadding -import ca.mpreg.webgpuviewer.renderer.GainmapInput -import ca.mpreg.webgpuviewer.renderer.Image -import ca.mpreg.webgpuviewer.viewer.ImagePage -import ca.mpreg.webgpuviewer.viewer.ImageViewer -import ca.mpreg.webgpuviewer.viewer.ImageViewerState import coil3.asDrawable import coil3.imageLoader import coil3.request.CachePolicy import coil3.request.ImageRequest -import coil3.request.SuccessResult import coil3.size.Size import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.manga.EditCoverAction -import eu.kanade.tachiyomi.data.coil.ImageDecoder -import eu.kanade.tachiyomi.data.coil.newDecoder import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import mihon.app.di.appGraph import mihon.icons.materialsymbols.MaterialSymbols import mihon.icons.materialsymbols.rounded.Close import mihon.icons.materialsymbols.rounded.Edit @@ -79,9 +65,6 @@ fun MangaCoverDialog( onEditClick: ((EditCoverAction) -> Unit)?, onDismissRequest: () -> Unit, ) { - val useNewRenderer = LocalContext.current.appGraph.basePreferences.highQualityRenderer.get() - val view = LocalView.current - Dialog( onDismissRequest = onDismissRequest, properties = DialogProperties( @@ -168,59 +151,6 @@ fun MangaCoverDialog( } }, ) { contentPadding -> - if (useNewRenderer) { - val state = remember(manga) { ImageViewerState() } - LaunchedEffect(manga) { - val request = ImageRequest.Builder(view.context) - .data(manga) - .size(Size.ORIGINAL) - .memoryCachePolicy(CachePolicy.DISABLED) - .newDecoder(true) - .build() - val result = view.context.imageLoader.execute(request) as? SuccessResult ?: return@LaunchedEffect - val res = result.image as? ImageDecoder.DecodeResultImage ?: return@LaunchedEffect - // Held, then freed, around the upload: its buffer alone doesn't keep - // the frame's native pixels alive. - val page = res.frame.use { - withContext(Dispatchers.Default) { - ImagePage.ImageSingle( - Image( - res.image, - res.width, - res.height, - createMipMaps = true, - backgroundColor = 0, - hdr = res.isHdr, - hdrHeadroom = res.hdrHeadroom, - gainmap = res.gainmap?.let { - GainmapInput( - pixels = it.pixels, - width = it.width, - height = it.height, - channels = it.channels, - gamma = it.gamma, - minContentBoost = it.minContentBoost, - maxContentBoost = it.maxContentBoost, - offsetSdr = it.offsetSdr, - offsetHdr = it.offsetHdr, - ) - }, - ), - ) - } - } - state.apply { - fetchPage = { index -> - if (index == 0) page else null - } - invalidate() - } - } - - ImageViewer(state = state) - return@Scaffold - } - val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt index 936f20689..29e5df859 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsAdvancedScreen.kt @@ -6,6 +6,8 @@ import android.content.Intent import android.provider.Settings import android.webkit.WebStorage import android.webkit.WebView +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.network.PREF_DOH_QUAD101 import eu.kanade.tachiyomi.network.PREF_DOH_QUAD9 import eu.kanade.tachiyomi.network.PREF_DOH_SHECAN import eu.kanade.tachiyomi.ui.more.OnboardingScreen +import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.isReleaseBuildType import eu.kanade.tachiyomi.util.system.isShizukuInstalled import eu.kanade.tachiyomi.util.system.powerManager @@ -53,6 +56,7 @@ import kotlinx.coroutines.withContext import logcat.LogPriority import mihon.app.di.appGraph import okhttp3.Headers +import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.i18n.MR @@ -333,12 +337,43 @@ object SettingsAdvancedScreen : SearchableSettings { basePreferences: BasePreferences, ): Preference.PreferenceGroup { val context = LocalContext.current + val chooseColorProfile = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri -> + uri?.let { + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + context.contentResolver.takePersistableUriPermission(uri, flags) + basePreferences.displayProfile.set(uri.toString()) + } + } return Preference.PreferenceGroup( title = stringResource(MR.strings.pref_category_reader), preferenceItems = listOf( - Preference.PreferenceItem.SwitchPreference( - preference = basePreferences.highQualityRenderer, - title = stringResource(MR.strings.pref_high_quality_renderer), + Preference.PreferenceItem.ListPreference( + preference = basePreferences.hardwareBitmapThreshold, + entries = GLUtil.CUSTOM_TEXTURE_LIMIT_OPTIONS + .mapIndexed { index, option -> + val display = if (index == 0) { + stringResource(MR.strings.pref_hardware_bitmap_threshold_default, option) + } else { + option.toString() + } + option to display + } + .toMap(), + title = stringResource(MR.strings.pref_hardware_bitmap_threshold), + subtitleProvider = { value, options -> + stringResource(MR.strings.pref_hardware_bitmap_threshold_summary, options[value].orEmpty()) + }, + visible = !ImageUtil.HARDWARE_BITMAP_UNSUPPORTED && + GLUtil.DEVICE_TEXTURE_LIMIT > GLUtil.SAFE_TEXTURE_LIMIT, + ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.pref_display_profile), + subtitle = basePreferences.displayProfile.get(), + onClick = { + chooseColorProfile.launch(arrayOf("*/*")) + }, ), ), ) diff --git a/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt b/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt index 62c34176b..462bb0321 100644 --- a/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt +++ b/app/src/main/java/eu/kanade/presentation/reader/settings/ReadingModePage.kt @@ -15,7 +15,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode -import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer import mihon.app.di.appGraph import tachiyomi.i18n.MR @@ -31,7 +30,6 @@ import java.text.NumberFormat internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { HeadingItem(MR.strings.pref_category_for_this_series) val manga by viewModel.mangaFlow.collectAsState() - val viewer by viewModel.viewerFlow.collectAsState() val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } SettingsChipRow(MR.strings.pref_category_reading_mode) { @@ -44,62 +42,6 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } - if (viewer is WebGpuViewer) { - val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() - val resolved = ReadingMode.fromPreference( - when { - readingMode == ReadingMode.DEFAULT -> default - else -> manga?.readingMode?.toInt() ?: default - }, - ) - if (resolved == ReadingMode.LEFT_TO_RIGHT || resolved == ReadingMode.RIGHT_TO_LEFT) { - val dualPageView by viewModel.preferences.dualPageView.collectAsState() - SettingsChipRow(MR.strings.pref_dual_page_view) { - ReaderPreferences.DualPageView.entries.map { - FilterChip( - selected = it == dualPageView, - onClick = { viewModel.preferences.dualPageView.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - } - - if (resolved == ReadingMode.WEBTOON || resolved == ReadingMode.CONTINUOUS_VERTICAL) { - val numberFormat = remember { NumberFormat.getPercentInstance() } - val continuousMinWidth by viewModel.preferences.continuousMinWidth.collectAsState() - SliderItem( - value = continuousMinWidth, - valueRange = ReaderPreferences.let { 1..100 }, - label = stringResource(MR.strings.pref_continuous_minwidth), - valueString = numberFormat.format(continuousMinWidth / 100f), - onChange = { - viewModel.preferences.continuousMinWidth.set(it) - }, - pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ) - - if (resolved == ReadingMode.CONTINUOUS_VERTICAL) { - val continuousGap by viewModel.preferences.continuousGap.collectAsState() - SliderItem( - value = continuousGap, - valueRange = ReaderPreferences.let { 1..100 }, - label = stringResource(MR.strings.pref_continuous_gap), - valueString = numberFormat.format(continuousGap / 100f), - onChange = { - viewModel.preferences.continuousGap.set(it) - }, - pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, - ) - } - - CheckboxItem( - label = stringResource(MR.strings.pref_webtoon_disable_zoom_out), - pref = viewModel.preferences.webtoonDisableZoomOut, - ) - } - } - val orientation = remember(manga) { ReaderOrientation.fromPreference(manga?.readerOrientation?.toInt()) } SettingsChipRow(MR.strings.rotation_type) { ReaderOrientation.entries.map { @@ -111,10 +53,11 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) { } } - when (viewer) { - is WebtoonViewer -> WebtoonViewerSettings(viewModel) - is WebGpuViewer -> WebGpuViewerSettings(viewModel) - else -> PagerViewerSettings(viewModel) + val viewer by viewModel.viewerFlow.collectAsState() + if (viewer is WebtoonViewer) { + WebtoonViewerSettings(viewModel) + } else { + PagerViewerSettings(viewModel) } } @@ -292,120 +235,3 @@ private fun ColumnScope.TapZonesItems( } } } - -@Composable -private fun ColumnScope.WebGpuViewerSettings(viewModel: ReaderSettingsViewModel) { - HeadingItem(MR.strings.webgpu_viewer) - - val manga by viewModel.mangaFlow.collectAsState() - val viewer by viewModel.viewerFlow.collectAsState() - - val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) } - val default = LocalContext.current.appGraph.readerPreferences.defaultReadingMode.get() - val resolved = ReadingMode.fromPreference( - when { - readingMode == ReadingMode.DEFAULT -> default - else -> manga?.readingMode?.toInt() ?: default - }, - ) - val isDual = (viewer as? WebGpuViewer)?.isDualPageMode() == true - - val navigationModePager by viewModel.preferences.navigationModePager.collectAsState() - val pagerNavInverted by viewModel.preferences.pagerNavInverted.collectAsState() - TapZonesItems( - selected = navigationModePager, - onSelect = viewModel.preferences.navigationModePager::set, - invertMode = pagerNavInverted, - onSelectInvertMode = viewModel.preferences.pagerNavInverted::set, - ) - - if (isDual) { - val transitionAnimation by viewModel.preferences.transitionAnimationDual.collectAsState() - SettingsChipRow(MR.strings.pref_transition_animation_dual) { - ( - ReaderPreferences.TransitionAnimation.entries - ReaderPreferences.TransitionAnimation.FLIP_LEFT - - ReaderPreferences.TransitionAnimation.FLIP_RIGHT - ).forEach { - FilterChip( - selected = it == transitionAnimation, - onClick = { viewModel.preferences.transitionAnimationDual.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - val cutoutMode by viewModel.preferences.cutoutModeDual.collectAsState() - SettingsChipRow(MR.strings.pref_cutout_mode_dual) { - ReaderPreferences.CutoutMode.entries.forEach { - FilterChip( - selected = it == cutoutMode, - onClick = { viewModel.preferences.cutoutModeDual.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - return - } - - if (resolved != ReadingMode.WEBTOON && resolved != ReadingMode.CONTINUOUS_VERTICAL) { - val imageScaleType by viewModel.preferences.imageScaleType.collectAsState() - SettingsChipRow(MR.strings.pref_image_scale_type) { - ReaderPreferences.ImageScaleTypeWebGpuViewer.forEach { - FilterChip( - selected = ReaderPreferences.ImageScaleType[imageScaleType - 1] == it, - onClick = { - viewModel.preferences.imageScaleType.set(ReaderPreferences.ImageScaleType.indexOf(it) + 1) - }, - label = { Text(stringResource(it)) }, - ) - } - } - - val zoomStart by viewModel.preferences.zoomStart.collectAsState() - SettingsChipRow(MR.strings.pref_zoom_start) { - ReaderPreferences.ZoomStart.mapIndexed { index, it -> - FilterChip( - selected = zoomStart == index + 1, - onClick = { viewModel.preferences.zoomStart.set(index + 1) }, - label = { Text(stringResource(it)) }, - ) - } - } - - CheckboxItem( - label = stringResource(MR.strings.pref_crop_borders), - pref = viewModel.preferences.cropBorders, - ) - - CheckboxItem( - label = stringResource(MR.strings.pref_landscape_zoom), - pref = viewModel.preferences.landscapeZoom, - ) - - CheckboxItem( - label = stringResource(MR.strings.pref_navigate_pan), - pref = viewModel.preferences.navigateToPan, - ) - - val transitionAnimation by viewModel.preferences.transitionAnimation.collectAsState() - SettingsChipRow(MR.strings.pref_transition_animation) { - ReaderPreferences.TransitionAnimation.entries.forEach { - FilterChip( - selected = it == transitionAnimation, - onClick = { viewModel.preferences.transitionAnimation.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - - val cutoutMode by viewModel.preferences.cutoutMode.collectAsState() - SettingsChipRow(MR.strings.pref_cutout_mode) { - ReaderPreferences.CutoutMode.entries.forEach { - FilterChip( - selected = it == cutoutMode, - onClick = { viewModel.preferences.cutoutMode.set(it) }, - label = { Text(stringResource(it.titleRes)) }, - ) - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index f6e94d913..184df4ce7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -45,6 +45,7 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate import eu.kanade.tachiyomi.util.system.DeviceUtil +import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.cancelNotification @@ -68,6 +69,7 @@ import org.conscrypt.Conscrypt import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore +import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.source.service.SourceManager import tachiyomi.i18n.MR @@ -184,6 +186,14 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor .onEach(TelemetryConfig::setCrashlyticsEnabled) .launchIn(scope) + basePreferences.hardwareBitmapThreshold.let { preference -> + if (!preference.isSet()) preference.set(GLUtil.DEVICE_TEXTURE_LIMIT) + } + + basePreferences.hardwareBitmapThreshold.changes() + .onEach { ImageUtil.hardwareBitmapThreshold = it } + .launchIn(scope) + setAppCompatDelegateThemeMode(uiPreferences.themeMode.get()) // Updates widget update diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt index 9f3ec6611..05b7b432f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/ChapterCache.kt @@ -7,7 +7,6 @@ import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.ui.reader.model.DownloadStream import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.saveTo import kotlinx.serialization.json.Json @@ -151,11 +150,10 @@ class ChapterCache( * * @param imageUrl url of image. * @param response http response from page. - * @param live also receives the bytes as they arrive. * @throws IOException image error. */ @Throws(IOException::class) - fun putImageToCache(imageUrl: String, response: Response, live: DownloadStream? = null) { + fun putImageToCache(imageUrl: String, response: Response) { // Initialize editor (edits the values for an entry). var editor: DiskLruCache.Editor? = null @@ -165,22 +163,7 @@ class ChapterCache( editor = diskCache.edit(key) ?: return // Get OutputStream and write image with Okio. - if (live == null) { - response.body.source().saveTo(editor.newOutputStream(0)) - } else { - live.reserve(response.body.contentLength()) - response.body.source().use { source -> - editor.newOutputStream(0).use { out -> - val buffer = ByteArray(1 shl 16) - while (true) { - val n = source.read(buffer) - if (n < 0) break - out.write(buffer, 0, n) - live.append(buffer, 0, n) - } - } - } - } + response.body.source().saveTo(editor.newOutputStream(0)) diskCache.flush() editor.commit() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt index 2bdf47fc0..181f75c74 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/ImageDecoder.kt @@ -2,9 +2,6 @@ package eu.kanade.tachiyomi.data.coil import android.graphics.Bitmap import android.graphics.Matrix -import androidx.core.graphics.createBitmap -import androidx.core.graphics.scale -import ca.mpreg.imagedecoder.ImageDecoder import coil3.Canvas import coil3.Image import coil3.ImageLoader @@ -15,39 +12,20 @@ import coil3.decode.Decoder import coil3.decode.ImageSource import coil3.fetch.SourceFetchResult import coil3.request.Options +import coil3.request.bitmapConfig import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import okio.BufferedSource import tachiyomi.core.common.util.system.ImageUtil +import tachiyomi.decoder.ImageDecoder import kotlin.math.min import kotlin.math.sqrt /** - * A [Decoder] that uses [ImageDecoder] to decode image formats not supported - * by the Android system decoder (AVIF, JXL, HEIF, etc.). + * A [Decoder] that uses built-in [ImageDecoder] to decode images that is not supported by the system. */ class ImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder { - /** - * Wraps a raw [ImageDecoder.Frame] as a Coil [Image] for callers that want - * direct access to the RGBA [java.nio.ByteBuffer] (e.g. the new-decoder path). - */ - class DecodeResultImage( - val frame: ImageDecoder.Frame, - val isHdr: Boolean, - val hdrHeadroom: Float, - val gainmap: ImageDecoder.Gainmap?, - ) : Image { - val image: java.nio.ByteBuffer get() = frame.image - - // Taken now: a caller may close the frame once it has the pixels. - override val size: Long = frame.image.capacity().toLong() - override val width: Int get() = frame.width - override val height: Int get() = frame.height - override val shareable: Boolean get() = true - override fun draw(canvas: Canvas) {} - } - /** * A page cut into immutable strips of about [STRIP_BYTES] each. The renderer uploads an * immutable bitmap only when it is drawn, so only the strips on screen reach the GPU instead of @@ -64,32 +42,18 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti override suspend fun decode(): DecodeResult { if (options.webtoonStrips) return decodeStrips() - val res = resources.source().use { - ImageDecoder.open(it.inputStream()).use { dec -> - DecodeResultImage( - dec.decodeNext(), - dec.isHdr, - dec.hdrHeadroom, - if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null, - ) - } + val decoder = resources.sourceOrNull()?.use { + ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile) } - val srcWidth = res.width - val srcHeight = res.height + check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" } - // newDecoder path: caller wants the raw DecodeResult (e.g. for custom rendering). - // Hand it back as-is; sampling is the caller's responsibility. - if (options.newDecoder) { - return DecodeResult( - image = res, - isSampled = false, - ) - } + val srcWidth = decoder.width + val srcHeight = decoder.height - // Normal path: produce a Bitmap scaled to the requested output size. val dstWidth = options.size.widthPx(options.scale) { srcWidth } val dstHeight = options.size.heightPx(options.scale) { srcHeight } + val sampleSize = DecodeUtils.calculateInSampleSize( srcWidth = srcWidth, srcHeight = srcHeight, @@ -98,25 +62,17 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti scale = options.scale, ) - // Copy RGBA pixels from the native buffer into a full-resolution bitmap. - // We must do this while `res` (and its native memory) is still alive. - // HDR frames are half-float RGBA. - val config = if (res.isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888 - val fullBitmap = createBitmap(srcWidth, srcHeight, config) - res.image.rewind() - fullBitmap.copyPixelsFromBuffer(res.image) - res.frame.close() + var bitmap = decoder.decode(sampleSize = sampleSize) + decoder.recycle() - // Downsample if needed. sampleSize is a power-of-two factor; the target - // dimensions are src / sampleSize, matching BitmapFactory inSampleSize behaviour. - val bitmap = if (sampleSize > 1) { - val scaledWidth = (srcWidth / sampleSize).coerceAtLeast(1) - val scaledHeight = (srcHeight / sampleSize).coerceAtLeast(1) - val scaled = fullBitmap.scale(scaledWidth, scaledHeight) - fullBitmap.recycle() - scaled - } else { - fullBitmap + check(bitmap != null) { "Failed to decode image" } + + if (options.bitmapConfig == Bitmap.Config.HARDWARE && ImageUtil.canUseHardwareBitmap(bitmap)) { + val hwBitmap = bitmap.copy(Bitmap.Config.HARDWARE, false) + if (hwBitmap != null) { + bitmap.recycle() + bitmap = hwBitmap + } } return DecodeResult( @@ -126,66 +82,62 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti } /** - * Copies bands of rows straight from the native frame into strips, so the page never exists as - * one Java bitmap on top of the frame. + * Decodes a webtoon page and cuts it into strips, freeing the decoded page once they're copied. * * Strips keep the resolution a power-of-two sample size leaves, like the other paths, so zooming * in still shows the page's detail, unless that would make the page take more than * [MAX_PAGE_BYTES]. They are never narrower than the view, so the page stays sharp unzoomed. */ private suspend fun decodeStrips(): DecodeResult = stripDecodes.withPermit { - val (frame, isHdr) = resources.source().use { - ImageDecoder.open(it.inputStream()).use { dec -> dec.decodeNext() to dec.isHdr } + val decoder = resources.sourceOrNull()?.use { + ImageDecoder.newInstance(it.inputStream(), options.cropBorders, displayProfile) } - frame.use { - val config = if (isHdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888 - val bytesPerPixel = if (isHdr) 8 else 4 - val rowBytes = frame.width * bytesPerPixel - val viewWidth = options.size.widthPx(options.scale) { frame.width } - val sampleSize = DecodeUtils.calculateInSampleSize( - srcWidth = frame.width, - srcHeight = frame.height, - dstWidth = viewWidth, - dstHeight = options.size.heightPx(options.scale) { frame.height }, - scale = options.scale, - ) - val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * frame.width / frame.height).toInt() - val stripWidth = maxOf(viewWidth, min(frame.width / sampleSize, budgetWidth)) - // Older GPUs can't draw a texture wider than this - .coerceAtMost(MAX_STRIP_WIDTH) - .coerceIn(1, frame.width) - val scale = stripWidth.toFloat() / frame.width + check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" } - // Strips of about the same size in bytes, so each takes about as long to upload - val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1) - val bandRows = (stripRows / scale).toInt().coerceIn(1, frame.height) - val matrix = Matrix().apply { setScale(scale, scale) } - val band = createBitmap(frame.width, bandRows, config) - val strips = (0.. - val rows = min(bandRows, frame.height - top) - if (rows != band.height) band.reconfigure(frame.width, rows, config) - band.copyPixelsFromBuffer( - frame.image.duplicate().apply { - position(top * rowBytes) - limit((top + rows) * rowBytes) - }, - ) - val strip = if (stripWidth < frame.width) { - Bitmap.createBitmap(band, 0, 0, frame.width, rows, matrix, true) - } else { - band - } - // createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one - strip.copy(config, false).also { if (strip !== band) strip.recycle() } + val srcWidth = decoder.width + val viewWidth = options.size.widthPx(options.scale) { srcWidth } + val sampleSize = DecodeUtils.calculateInSampleSize( + srcWidth = decoder.width, + srcHeight = decoder.height, + dstWidth = viewWidth, + dstHeight = options.size.heightPx(options.scale) { decoder.height }, + scale = options.scale, + ) + val page = decoder.decode(sampleSize = sampleSize) + decoder.recycle() + check(page != null) { "Failed to decode image" } + + val config = page.config ?: Bitmap.Config.ARGB_8888 + val bytesPerPixel = page.rowBytes / page.width + val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * page.width / page.height).toInt() + val stripWidth = maxOf(viewWidth, min(page.width, budgetWidth)) + // Older GPUs can't draw a texture wider than this + .coerceAtMost(MAX_STRIP_WIDTH) + .coerceIn(1, page.width) + val scale = stripWidth.toFloat() / page.width + + // Strips of about the same size in bytes, so each takes about as long to upload + val stripRows = (STRIP_BYTES / (stripWidth * bytesPerPixel)).coerceAtLeast(1) + val bandRows = (stripRows / scale).toInt().coerceIn(1, page.height) + val matrix = Matrix().apply { setScale(scale, scale) } + val strips = (0.. + val rows = min(bandRows, page.height - top) + val band = if (stripWidth < page.width) { + Bitmap.createBitmap(page, 0, top, page.width, rows, matrix, true) + } else { + Bitmap.createBitmap(page, 0, top, page.width, rows) } - band.recycle() - DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width) + // createBitmap returns a mutable bitmap despite its docs, and copy is the only way to an immutable one + band.copy(config, false).also { if (band !== page) band.recycle() } } + page.recycle() + DecodeResult(image = StripsImage(strips), isSampled = stripWidth < srcWidth) } class Factory : Decoder.Factory { + override fun create(result: SourceFetchResult, options: Options, imageLoader: ImageLoader): Decoder? { - return if (options.newDecoder || options.webtoonStrips || isApplicable(result.source.source())) { + return if (options.webtoonStrips || isApplicable(result.source.source())) { ImageDecoder(result.source, options) } else { null @@ -197,12 +149,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti ImageUtil.findImageType(it) } return when (type) { - ImageUtil.ImageType.AVIF, - ImageUtil.ImageType.JXL, - ImageUtil.ImageType.HEIF, - ImageUtil.ImageType.JP2, - -> true - + ImageUtil.ImageType.AVIF, ImageUtil.ImageType.JXL, ImageUtil.ImageType.HEIF -> true else -> false } } @@ -211,6 +158,10 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti override fun hashCode() = javaClass.hashCode() } + + companion object { + var displayProfile: ByteArray? = null + } } private const val STRIP_BYTES = 4 * 1024 * 1024 @@ -219,5 +170,5 @@ private const val MAX_PAGE_BYTES = 128 * 1024 * 1024 private const val MAX_STRIP_WIDTH = 4096 -// Each decode holds the whole native frame until its strips are copied, and the reader loads pages ahead in parallel +// Each decode holds the whole page until its strips are copied, and the reader loads pages ahead in parallel private val stripDecodes = Semaphore(1) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt index c796bda3a..0400afc6f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt @@ -42,11 +42,3 @@ val Options.webtoonStrips: Boolean get() = getExtra(webtoonStripsKey) private val webtoonStripsKey = Extras.Key(default = false) - -fun ImageRequest.Builder.newDecoder(enable: Boolean) = apply { - extras[newDecoderKey] = enable -} -val Options.newDecoder: Boolean - get() = getExtra(newDecoderKey) - -private val newDecoderKey = Extras.Key(default = false) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index 108a9e3fc..8232cbd71 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -43,7 +43,9 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope +import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.google.android.material.transition.platform.MaterialContainerTransform +import com.hippo.unifile.UniFile import dev.zacsweers.metro.Inject import eu.kanade.domain.base.BasePreferences import eu.kanade.presentation.reader.DisplayRefreshHost @@ -56,6 +58,7 @@ import eu.kanade.presentation.reader.appbars.ReaderAppBars import eu.kanade.presentation.reader.components.ChapterNavigatorType import eu.kanade.presentation.reader.settings.ReaderSettingsDialog import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.coil.ImageDecoder import eu.kanade.tachiyomi.data.notification.NotificationReceiver import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.databinding.ReaderActivityBinding @@ -74,7 +77,6 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsViewModel import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer -import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer import eu.kanade.tachiyomi.ui.webview.WebViewActivity import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.readerBackgroundColor @@ -99,6 +101,7 @@ import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR import tachiyomi.presentation.core.util.collectAsState +import java.io.ByteArrayOutputStream import kotlin.time.Duration.Companion.seconds class ReaderActivity : BaseActivity() { @@ -473,7 +476,7 @@ class ReaderActivity : BaseActivity() { onShare = ::shareChapter.takeIf { isHttpSource }, chapterNavigatorType = if (!verticalNavigator) { - if (state.viewer is R2LPagerViewer || (state.viewer as? WebGpuViewer)?.isReversed ?: false) { + if (state.viewer is R2LPagerViewer) { ChapterNavigatorType.HORIZONTAL_RTL } else { ChapterNavigatorType.HORIZONTAL_LTR @@ -860,6 +863,10 @@ class ReaderActivity : BaseActivity() { } .launchIn(lifecycleScope) + preferences.displayProfile.changes() + .onEach { setDisplayProfile(it) } + .launchIn(lifecycleScope) + readerPreferences.keepScreenOn.changes() .onEach(::setKeepScreenOn) .launchIn(lifecycleScope) @@ -887,6 +894,25 @@ class ReaderActivity : BaseActivity() { .launchIn(lifecycleScope) } + /** + * Sets the display profile to [path]. + */ + private fun setDisplayProfile(path: String) { + val file = UniFile.fromUri(baseContext, path.toUri()) + if (file != null && file.exists()) { + val inputStream = file.openInputStream() + val outputStream = ByteArrayOutputStream() + inputStream.use { input -> + outputStream.use { output -> + input.copyTo(output) + } + } + val data = outputStream.toByteArray() + SubsamplingScaleImageView.setDisplayProfile(data) + ImageDecoder.displayProfile = data + } + } + /** * Sets the keep screen on mode according to [enabled]. */ diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt index be6a68fc6..acd5e5962 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/HttpPageLoader.kt @@ -231,23 +231,14 @@ internal class HttpPageLoader( val imageUrl = page.imageUrl!! if (force || !chapterCache.isImageInCache(imageUrl)) { - val live = page.downloadStream page.status = Page.State.DownloadImage - try { - val imageResponse = source.getImage(page) - chapterCache.putImageToCache(imageUrl, imageResponse, live) - live?.finish() - } catch (e: Throwable) { - live?.finish(e) - throw e - } + val imageResponse = source.getImage(page) + chapterCache.putImageToCache(imageUrl, imageResponse) } - page.downloadStream = null page.stream = { chapterCache.getImageFile(imageUrl).inputStream() } page.status = Page.State.Ready } catch (e: Throwable) { - page.downloadStream = null if (e is CancellationException) { // Chunk chains get cancelled on every page turn, so a cancelled page isn't an error. Put it back in // the queue, otherwise it's stuck in a state no later pass will pick up. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/DownloadStream.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/DownloadStream.kt deleted file mode 100644 index 53b6ea114..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/DownloadStream.kt +++ /dev/null @@ -1,130 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.model - -import java.io.IOException -import java.io.InputStream - -/** - * A download's bytes as they arrive, for one reader. Past [MAX_BYTES], or once that reader is - * closed, it stops keeping them; [append] never throws, so the download itself carries on. - */ -class DownloadStream { - private val lock = Object() - private var data = ByteArray(1 shl 16) - private var size = 0 - private var done = false - private var error: Throwable? = null - - /** Presizes for [bytes], e.g. Content-Length; ignored if implausible. */ - fun reserve(bytes: Long) { - synchronized(lock) { - if (error == null && bytes > data.size && bytes <= MAX_RESERVE) { - try { - data = data.copyOf(bytes.toInt()) - } catch (e: OutOfMemoryError) { - // Only a hint. - } - } - } - } - - fun append(src: ByteArray, offset: Int, length: Int) { - synchronized(lock) { - if (error != null || done) return - val needed = size.toLong() + length - if (needed > MAX_BYTES) { - // Too big to decode while downloading; the whole-file decode takes over. - drop("Download too large to stream") - return - } - if (needed > data.size) { - data = try { - data.copyOf(maxOf(needed, minOf(data.size.toLong() * 2, MAX_BYTES)).toInt()) - } catch (e: OutOfMemoryError) { - // As too large: the download mustn't fail over this copy. - drop("Out of memory buffering the download") - return - } - } - System.arraycopy(src, offset, data, size, length) - size += length - lock.notifyAll() - } - } - - /** Ends the bytes; readers throw [error] if given. */ - fun finish(error: Throwable? = null) { - synchronized(lock) { - done = true - if (this.error == null) this.error = error - lock.notifyAll() - } - } - - /** From the start; [Reader.read] blocks for bytes. Closing it drops what's kept. */ - fun reader(): Reader = Reader() - - // Rather than hold a second copy of the page. - private fun drop(why: String) { - if (error == null) error = IOException(why) - data = ByteArray(0) - size = 0 - lock.notifyAll() - } - - private companion object { - // As the decoder's own input limit. - const val MAX_BYTES = 1L shl 30 - const val MAX_RESERVE = 64L shl 20 - } - - inner class Reader : InputStream() { - private var position = 0 - - /** All read; throws if the download failed. */ - val ended: Boolean - get() = synchronized(lock) { - checkError() - done && position >= size - } - - override fun read(): Int { - val b = ByteArray(1) - return if (read(b, 0, 1) < 0) -1 else b[0].toInt() and 0xFF - } - - override fun read(b: ByteArray, off: Int, len: Int): Int { - synchronized(lock) { - if (len == 0) return 0 - while (position >= size && !done && error == null) lock.wait() - checkError() - if (position >= size) return -1 - val n = minOf(len, size - position) - System.arraycopy(data, position, b, off, n) - position += n - return n - } - } - - override fun available(): Int = synchronized(lock) { if (error != null) 0 else size - position } - - /** Waits up to [timeoutMs] for [min] unread bytes or the end. */ - fun await(min: Int, timeoutMs: Long) { - synchronized(lock) { - val until = System.nanoTime() + timeoutMs * 1_000_000 - while (size - position < min && !done && error == null) { - val left = (until - System.nanoTime()) / 1_000_000 - if (left <= 0) return - lock.wait(left) - } - } - } - - override fun close() { - synchronized(lock) { drop("No longer read") } - } - - private fun checkError() { - error?.let { throw IOException("Download failed", it) } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt index f010229e9..6602b9618 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/model/ReaderPage.kt @@ -11,8 +11,4 @@ open class ReaderPage( ) : Page(index, url, imageUrl, null) { open lateinit var chapter: ReaderChapter - - /** Set by a viewer before the download; filled during [Page.State.DownloadImage]. */ - @Volatile - var downloadStream: DownloadStream? = null } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt index 2109ce68f..6270c961a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReaderPreferences.kt @@ -142,11 +142,6 @@ class ReaderPreferences( false, ) - val dualPageView: Preference = preferenceStore.getEnum( - "pref_dual_page_view", - DualPageView.NEVER, - ) - // endregion // region Color filter @@ -204,24 +199,6 @@ class ReaderPreferences( // endregion - // region WebGpu - - val transitionAnimation: Preference = - preferenceStore.getEnum("webgpu_transition_animation", TransitionAnimation.BASIC) - - val transitionAnimationDual: Preference = - preferenceStore.getEnum("webgpu_dual_transition_animation", TransitionAnimation.BASIC) - - val cutoutMode: Preference = preferenceStore.getEnum("webgpu_cutout_mode", CutoutMode.AVOID) - - val cutoutModeDual: Preference = preferenceStore.getEnum("webgpu_dual_cutout_mode", CutoutMode.IGNORE) - - val continuousMinWidth: Preference = preferenceStore.getInt("webgpu_continuous_minwidth", 100) - - val continuousGap: Preference = preferenceStore.getInt("webgpu_continuous_gap", 10) - - // endregion - enum class FlashColor { BLACK, WHITE, @@ -234,14 +211,8 @@ class ReaderPreferences( val shouldInvertVertical: Boolean = false, ) { NONE(MR.strings.tapping_inverted_none), - HORIZONTAL( - MR.strings.tapping_inverted_horizontal, - shouldInvertHorizontal = true, - ), - VERTICAL( - MR.strings.tapping_inverted_vertical, - shouldInvertVertical = true, - ), + HORIZONTAL(MR.strings.tapping_inverted_horizontal, shouldInvertHorizontal = true), + VERTICAL(MR.strings.tapping_inverted_vertical, shouldInvertVertical = true), BOTH(MR.strings.tapping_inverted_both, shouldInvertHorizontal = true, shouldInvertVertical = true), } @@ -252,43 +223,6 @@ class ReaderPreferences( LOWEST(47), } - enum class TransitionAnimation(val titleRes: StringResource) { - BASIC(MR.strings.transition_animation_basic), - FLIP(MR.strings.transition_animation_flip), - FLIP_LEFT(MR.strings.transition_animation_flip_left), - FLIP_RIGHT( - MR.strings.transition_animation_flip_right, - ), - STACK_LEFT(MR.strings.transition_animation_stack_left), - STACK_RIGHT(MR.strings.transition_animation_stack_right), - STACK_UP( - MR.strings.transition_animation_stack_up, - ), - STACK_DOWN(MR.strings.transition_animation_stack_down), - SPHERE(MR.strings.transition_animation_sphere), - CUBE_INSIDE( - MR.strings.transition_animation_cube_inside, - ), - CUBE_OUTSIDE(MR.strings.transition_animation_cube_outside), - FADE(MR.strings.transition_animation_fade), - FADE_WHITE( - MR.strings.transition_animation_fade_white, - ), - NONE(MR.strings.transition_animation_none), - } - - enum class CutoutMode(val titleRes: StringResource) { - IGNORE(MR.strings.cutout_mode_ignore), - AVOID(MR.strings.cutout_mode_avoid), - SHIFT(MR.strings.cutout_mode_shift), - } - - enum class DualPageView(val titleRes: StringResource) { - NEVER(MR.strings.dual_page_view_never), - ALWAYS(MR.strings.dual_page_view_always), - WIDE(MR.strings.dual_page_view_wide), - } - companion object { const val WEBTOON_PADDING_MIN = 0 const val WEBTOON_PADDING_MAX = 25 @@ -313,13 +247,6 @@ class ReaderPreferences( MR.strings.scale_type_smart_fit, ) - val ImageScaleTypeWebGpuViewer = listOf( - MR.strings.scale_type_fit_screen, - MR.strings.scale_type_fit_width, - MR.strings.scale_type_fit_height, - MR.strings.scale_type_original_size, - ) - val ZoomStart = listOf( MR.strings.zoom_start_automatic, MR.strings.zoom_start_left, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt index 28148550c..0e3fdd4ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/setting/ReadingMode.kt @@ -7,10 +7,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.Viewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.L2RPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer import eu.kanade.tachiyomi.ui.reader.viewer.pager.VerticalPagerViewer -import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewer -import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuViewerContinuous import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer -import mihon.app.di.appGraph import mihon.icons.custommaterialsymbols.CustomMaterialSymbols import mihon.icons.custommaterialsymbols.rounded.ReaderLeftToRight import mihon.icons.custommaterialsymbols.rounded.ReaderLongStrip @@ -77,16 +74,6 @@ enum class ReadingMode( } fun toViewer(preference: Int?, activity: ReaderActivity): Viewer { - if (activity.appGraph.basePreferences.highQualityRenderer.get()) { - return when (fromPreference(preference)) { - LEFT_TO_RIGHT -> WebGpuViewer(activity, isReversed = false, isVertical = false) - RIGHT_TO_LEFT -> WebGpuViewer(activity, isReversed = true, isVertical = false) - VERTICAL -> WebGpuViewer(activity, isReversed = false, isVertical = true) - WEBTOON -> WebGpuViewerContinuous(activity, useGap = false) - CONTINUOUS_VERTICAL -> WebGpuViewerContinuous(activity, useGap = true) - DEFAULT -> throw IllegalStateException("Preference value must be resolved: $preference") - } - } return when (fromPreference(preference)) { LEFT_TO_RIGHT -> L2RPagerViewer(activity) RIGHT_TO_LEFT -> R2LPagerViewer(activity) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt index 46bc08af3..901fd430d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/ReaderPageImageView.kt @@ -41,6 +41,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonStripImageView import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.view.isVisibleOnScreen import okio.BufferedSource +import tachiyomi.core.common.util.system.ImageUtil /** * A wrapper view for showing page image. @@ -240,6 +241,7 @@ open class ReaderPageImageView @JvmOverloads constructor( removeView(pageView) pageView = SubsamplingScaleImageView(context).apply { + setMaxTileSize(ImageUtil.hardwareBitmapThreshold) setDoubleTapZoomStyle(SubsamplingScaleImageView.ZOOM_FOCUS_CENTER) setPanLimit(SubsamplingScaleImageView.PAN_LIMIT_INSIDE) setMinimumTileDpi(180) @@ -300,6 +302,7 @@ open class ReaderPageImageView @JvmOverloads constructor( isVisible = true } is BufferedSource -> { + setHardwareConfig(ImageUtil.canUseHardwareBitmap(data)) setImage(ImageSource.inputStream(data.inputStream())) isVisible = true } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt deleted file mode 100644 index 886aac5f5..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuConfig.kt +++ /dev/null @@ -1,212 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.viewer.webgpu - -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences -import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView -import eu.kanade.tachiyomi.ui.reader.viewer.ViewerConfig -import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation -import eu.kanade.tachiyomi.ui.reader.viewer.navigation.DisabledNavigation -import eu.kanade.tachiyomi.ui.reader.viewer.navigation.EdgeNavigation -import eu.kanade.tachiyomi.ui.reader.viewer.navigation.KindlishNavigation -import eu.kanade.tachiyomi.ui.reader.viewer.navigation.LNavigation -import eu.kanade.tachiyomi.ui.reader.viewer.navigation.RightAndLeftNavigation -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach - -/** - * Configuration used by pager viewers. - */ -class WebGpuConfig( - private val viewer: WebGpuViewer, - scope: CoroutineScope, - readerPreferences: ReaderPreferences, -) : ViewerConfig(readerPreferences, scope) { - - var theme = readerPreferences.readerTheme.get() - private set - - var automaticBackground = false - private set - - var dualPageSplitChangedListener: ((Boolean) -> Unit)? = null - - var imageScaleType = 1 - private set - - var imageZoomType = ReaderPageImageView.ZoomStartPosition.LEFT - private set - - var imageCropBorders = false - private set - - var imageCropBordersWebtoon = false - private set - - var navigateToPan = false - private set - - var landscapeZoom = false - private set - - var transitionAnimation = ReaderPreferences.TransitionAnimation.BASIC - private set - - var transitionAnimationDual = ReaderPreferences.TransitionAnimation.BASIC - private set - - var cutoutMode = ReaderPreferences.CutoutMode.AVOID - private set - - var cutoutModeDual = ReaderPreferences.CutoutMode.AVOID - private set - - var dualPageView = ReaderPreferences.DualPageView.NEVER - private set - - var continuousMinWidth = 100 - private set - - var zoomOutDisabled = false - private set - - var continuousGap = 10 - private set - - init { - readerPreferences.readerTheme.register( - { - theme = it - automaticBackground = it == 3 - }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.imageScaleType.register({ imageScaleType = it }, { imagePropertyChangedListener?.invoke() }) - - readerPreferences.zoomStart.register({ zoomTypeFromPreference(it) }, { imagePropertyChangedListener?.invoke() }) - - readerPreferences.cropBorders.register({ imageCropBorders = it }, { imagePropertyChangedListener?.invoke() }) - - readerPreferences.cropBordersWebtoon.register( - { imageCropBordersWebtoon = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.navigateToPan.register({ navigateToPan = it }) - - readerPreferences.landscapeZoom.register({ landscapeZoom = it }, { imagePropertyChangedListener?.invoke() }) - - readerPreferences.navigationModePager.register({ navigationMode = it }, { updateNavigation(navigationMode) }) - - readerPreferences.pagerNavInverted.register({ tappingInverted = it }, { navigator.invertMode = it }) - readerPreferences.pagerNavInverted.changes().drop(1).onEach { navigationModeChangedListener?.invoke() } - .launchIn(scope) - - readerPreferences.dualPageSplitPaged.register( - { dualPageSplit = it }, - { - imagePropertyChangedListener?.invoke() - dualPageSplitChangedListener?.invoke(it) - }, - ) - - readerPreferences.dualPageInvertPaged.register( - { dualPageInvert = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.dualPageRotateToFit.register( - { dualPageRotateToFit = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.dualPageRotateToFitInvert.register( - { dualPageRotateToFitInvert = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.transitionAnimation.register( - { transitionAnimation = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.transitionAnimationDual.register( - { transitionAnimationDual = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.cutoutMode.register( - { cutoutMode = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.cutoutModeDual.register( - { cutoutModeDual = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.dualPageView.register( - { dualPageView = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.continuousMinWidth.register( - { continuousMinWidth = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.webtoonDisableZoomOut.register( - { zoomOutDisabled = it }, - { imagePropertyChangedListener?.invoke() }, - ) - - readerPreferences.continuousGap.register( - { continuousGap = it }, - { imagePropertyChangedListener?.invoke() }, - ) - } - - private fun zoomTypeFromPreference(value: Int) { - imageZoomType = when (value) { - // Auto - 1 -> if (viewer.isReversed) { - ReaderPageImageView.ZoomStartPosition.RIGHT - } else { - ReaderPageImageView.ZoomStartPosition.LEFT - } - // Left - 2 -> ReaderPageImageView.ZoomStartPosition.LEFT - // Right - 3 -> ReaderPageImageView.ZoomStartPosition.RIGHT - // Center - else -> ReaderPageImageView.ZoomStartPosition.CENTER - } - } - - override var navigator: ViewerNavigation = defaultNavigation() - set(value) { - field = value.also { it.invertMode = this.tappingInverted } - } - - override fun defaultNavigation(): ViewerNavigation { - return if (viewer.isVertical) { - LNavigation() - } else { - RightAndLeftNavigation() - } - } - - override fun updateNavigation(navigationMode: Int) { - navigator = when (navigationMode) { - 0 -> defaultNavigation() - 1 -> LNavigation() - 2 -> KindlishNavigation() - 3 -> EdgeNavigation() - 4 -> RightAndLeftNavigation() - 5 -> DisabledNavigation() - else -> defaultNavigation() - } - navigationModeChangedListener?.invoke() - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt deleted file mode 100644 index 55262b47b..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewer.kt +++ /dev/null @@ -1,2064 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.viewer.webgpu - -import android.graphics.Color -import android.graphics.PointF -import android.graphics.Rect -import android.os.SystemClock -import android.view.InputDevice -import android.view.KeyEvent -import android.view.MotionEvent -import android.view.View -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animate -import androidx.compose.animation.core.spring -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastCoerceIn -import androidx.webgpu.GPUTexture -import ca.mpreg.imagedecoder.ImageDecoder -import ca.mpreg.webgpuviewer.ImageView -import ca.mpreg.webgpuviewer.closeTo -import ca.mpreg.webgpuviewer.draw.TextAlign -import ca.mpreg.webgpuviewer.renderer.GainmapInput -import ca.mpreg.webgpuviewer.renderer.Image -import ca.mpreg.webgpuviewer.transition.TransitionBasic -import ca.mpreg.webgpuviewer.transition.TransitionCube -import ca.mpreg.webgpuviewer.transition.TransitionCubeOuter -import ca.mpreg.webgpuviewer.transition.TransitionFade -import ca.mpreg.webgpuviewer.transition.TransitionFadeWhite -import ca.mpreg.webgpuviewer.transition.TransitionFlip -import ca.mpreg.webgpuviewer.transition.TransitionFlipLeft -import ca.mpreg.webgpuviewer.transition.TransitionFlipRight -import ca.mpreg.webgpuviewer.transition.TransitionNone -import ca.mpreg.webgpuviewer.transition.TransitionSphere -import ca.mpreg.webgpuviewer.transition.TransitionStackDown -import ca.mpreg.webgpuviewer.transition.TransitionStackLeft -import ca.mpreg.webgpuviewer.transition.TransitionStackRight -import ca.mpreg.webgpuviewer.transition.TransitionStackUp -import ca.mpreg.webgpuviewer.viewer.AnimationFrame -import ca.mpreg.webgpuviewer.viewer.ImagePage -import ca.mpreg.webgpuviewer.viewer.ImageViewerContinuousState -import com.google.android.material.color.MaterialColors -import eu.kanade.tachiyomi.source.model.Page -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.reader.loader.HttpPageLoader -import eu.kanade.tachiyomi.ui.reader.model.DownloadStream -import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter -import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import eu.kanade.tachiyomi.ui.reader.model.ViewerChapters -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences.TransitionAnimation -import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView.ZoomStartPosition -import eu.kanade.tachiyomi.ui.reader.viewer.Viewer -import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation.NavigationRegion -import eu.kanade.tachiyomi.util.system.createReaderThemeContext -import eu.kanade.tachiyomi.util.system.readerBackgroundColor -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.takeWhile -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import logcat.LogPriority -import mihon.app.di.appGraph -import tachiyomi.core.common.util.system.logcat -import java.io.FilterInputStream -import java.lang.ref.WeakReference -import java.util.TreeSet -import java.util.concurrent.Executors -import kotlin.math.abs -import kotlin.math.min -import kotlin.time.Duration.Companion.milliseconds - -private const val STREAM_CHUNK = 1 shl 18 - -/** Longest a streaming feed waits before rechecking that the page is still wanted. */ -private const val STREAM_WAIT_MS = 1000L - -/** Least time between feeds, so a trickle of small reads decodes and uploads in batches. */ -private const val STREAM_BATCH_MS = 33L - -open class WebGpuViewer( - val activity: ReaderActivity, - val isReversed: Boolean, - val isVertical: Boolean, - val pager: ImageView = ImageView(activity, isVertical = isVertical, isReversed = isReversed), -) : Viewer { - - open val isContinuous: Boolean = false - - val readerPreferences by lazy { activity.appGraph.readerPreferences } - - /** Resolved once: render() asks per frame, and createReaderThemeContext builds a Resources. */ - @Volatile - private var cachedBackgroundColor: Int? = null - - @Volatile - private var cachedOnBackgroundColor: Int? = null - - protected fun readerBackgroundColor(): Int = - cachedBackgroundColor ?: activity.baseContext.readerBackgroundColor(config.theme) - .also { cachedBackgroundColor = it } - - private fun readerOnBackgroundColor(): Int = cachedOnBackgroundColor ?: MaterialColors.getColor( - activity.createReaderThemeContext(), - com.google.android.material.R.attr.colorOnBackground, - Color.WHITE, - ).also { cachedOnBackgroundColor = it } - - protected val scope = MainScope() - - // Dedicated thread for decode worker to avoid blocking Dispatchers.Default pool - private val decodeExecutor = Executors.newSingleThreadExecutor { r -> - Thread(r, "WebGpuViewer-Decode").apply { isDaemon = true } - } - private val decodeDispatcher = decodeExecutor.asCoroutineDispatcher() - - // Streaming decodes block on the network. - private val streamExecutor = Executors.newSingleThreadExecutor { r -> - Thread(r, "WebGpuViewer-Stream").apply { isDaemon = true } - } - private val streamDispatcher = streamExecutor.asCoroutineDispatcher() - - // Animations decode as they play, a few at a time, apart from page decodes. - private val animationDispatcher = Dispatchers.Default.limitedParallelism(2) - - // Guards pageCache, decodeQueue, deferredCleanup and chapterPreloadsInFlight. - private val lock = Object() - - /** Without it the worker parks in [lock].wait() after [destroy], keeping the activity alive. */ - @Volatile - private var destroyed = false - - private val pageCache = LinkedHashMap() - - // Processed LIFO - last added is highest priority. - private val decodeQueue = ArrayDeque() - - // Each page's loader.loadPage call, which suspends until cancelled; cancelling drops its - // still-queued downloads from the loader. Under [lock]. - private val loadJobs = HashMap() - - // Each page's status watcher, from startPageLoad. Under [lock]. - private val watchJobs = HashMap() - - /** - * Indices of the pages that take a spread to themselves, by chapter - see [spreadStartIndex]. - * Outlives [pageCache]: every page after one of these depends on it, long since evicted. - */ - private val loneIndices = HashMap>() - - /** Chapters [preloadChapterThenRetry] is already waiting on, by id. */ - private val chapterPreloadsInFlight = HashSet() - - /** - * Which side of a dual-page spread a [ViewerReaderPage] belongs on - app-level bookkeeping - * for [getSpreadAnchor]/[buildSpreadPage], independent of the decoded image itself. - */ - internal enum class SpreadPosition { LEFT, RIGHT, SINGLE } - - /** Above this, an untagged page is a spread already, not half of one. */ - private val wideAspect = 1.2f - - /** How far two untagged pages' aspect ratios may differ and still pair. */ - private val pairAspectTolerance = 0.1f - - private sealed class PageKey { - data class Reader(val chapterId: Long?, val index: Int) : PageKey() - data class Transition(val prevId: Long?, val nextId: Long?) : PageKey() - } - - private fun pageKey(page: ViewerPage): PageKey = when (page) { - is ViewerReaderPage -> PageKey.Reader(page.page.chapter.chapter.id, page.page.index) - is ViewerTransitionPage -> PageKey.Transition(page.prevChapter?.chapter?.id, page.nextChapter?.chapter?.id) - else -> PageKey.Transition(null, null) - } - - private fun findInCache(key: PageKey): ViewerPage? = pageCache[key] - - protected fun viewerPageFor(imagePage: ImagePage): ViewerPage? = synchronized(lock) { - pageCache.values.firstOrNull { it.imagePage === imagePage } - } - - /** Check if a page is in the cache by identity. O(1) via key lookup. */ - private fun pageInCache(page: ViewerPage): Boolean = pageCache[pageKey(page)] === page - - /** - * Queue a page for decoding if not already queued/loading/decoded. - * If prioritize=true and page is already queued, moves it to front. - * Must be called while holding lock. - */ - private fun queueForDecode(page: ViewerReaderPage, prioritize: Boolean = false) { - // Already has a decoded image - if (page.isDecoded) return - - when (page.state) { - PageState.IDLE -> { - page.state = PageState.QUEUED - if (prioritize) { - decodeQueue.addLast(page) - } else { - decodeQueue.addFirst(page) - } - lock.notify() - } - - PageState.QUEUED -> { - if (prioritize && decodeQueue.remove(page)) { - decodeQueue.addLast(page) - } - } - - PageState.LOADING, PageState.DECODING -> {} - } - } - - init { - scope.launch(decodeDispatcher) { - try { - while (!destroyed) { - // Popped, vetted and marked under one acquisition - no window for an eviction. - val page = synchronized(lock) { - while (decodeQueue.isEmpty()) { - if (destroyed) return@launch - lock.wait() - } - val candidate = decodeQueue.removeLast() - if (pageInCache(candidate) && !candidate.isDecoded) { - candidate.apply { state = PageState.DECODING } - } else { - if (pageInCache(candidate)) candidate.state = PageState.IDLE - null - } - } ?: continue - - try { - decodeReaderPage(page) - } catch (e: CancellationException) { - // Caught below, the loop would park in wait() on a dead scope. - throw e - } catch (e: Throwable) { - if (e !is Exception && e !is OutOfMemoryError) throw e - logcat(LogPriority.ERROR, e) { "decodeReaderPage: ${e.message}" } - synchronized(lock) { - if (pageInCache(page) && !page.isDecoded && !page.imagePage.destroyed) { - val oldImagePage = page.imagePage - val errorMessage = e.message ?: "Failed to decode image" - page.imagePage = ErrorPage(errorMessage, page.spreadPosition) - page.preview = null - page.state = PageState.IDLE - cleanupImage(oldImagePage) - page.imagePage.invalidate() - } else { - if (pageInCache(page)) page.state = PageState.IDLE - } - } - } - } - } catch (_: InterruptedException) { - // destroy()'s shutdownNow, out of lock.wait(). - } catch (_: CancellationException) { - // Scope cancelled with the viewer. - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "Decode worker died" } - } - } - } - - /** - * Configuration used by the pager, like allow taps, scale mode on images, page transitions... - */ - val config = WebGpuConfig(this, scope, readerPreferences) - - // Read from the render and decode threads, via the prevChapter/nextChapter getters. - @Volatile - var viewerChapters: ViewerChapters? = null - - val pages: List? get() = (currentPage as? ViewerReaderPage)?.page?.chapter?.pages - - @Volatile - var currentPage: ViewerPage? = null - - /** - * What a running page turn animates away from, kept out of [evictFarthestPage]'s reach - a - * jump preloads enough pages to evict it. Replaced by the next turn's rather than cleared. - */ - @Volatile - private var pinnedFromPage: ImagePage? = null - - /** True while [pinnedFromPage] is drawing [image], as itself or as a spread side. */ - private fun isPinnedImage(image: ImagePage): Boolean { - val pinned = pinnedFromPage ?: return false - if (pinned === image) return true - return pinned is ImagePage.ImageSpread && (pinned.left === image || pinned.right === image) - } - - /** True while [pinnedFromPage] is drawing [page]'s image, as itself or as a spread side. */ - private fun isPinned(page: ViewerPage): Boolean = isPinnedImage(page.imagePage) - - /** Images swapped out while [pinnedFromPage] was still drawing them, i.e. mid page turn. */ - private val deferredCleanup = mutableListOf() - - /** Cleans up [image], or defers it while [pinnedFromPage] draws it. Call under [lock]. */ - private fun cleanupImage(image: ImagePage) { - if (isPinnedImage(image)) deferredCleanup.add(image) else image.cleanup() - } - - /** Releases what [pinnedFromPage] no longer protects. Call under [lock]. */ - private fun flushDeferredCleanup() { - val iterator = deferredCleanup.iterator() - while (iterator.hasNext()) { - val image = iterator.next() - if (!isPinnedImage(image)) { - image.cleanup() - iterator.remove() - } - } - } - - open val preloadAhead = 3 - open val preloadBehind = 2 - - /** - * Everything [preloadPages] reaches, plus slack. Sized exactly, a chapter transition page - or - * in dual mode a spread partner - evicts a page the next fetch asks for, and it decodes again. - */ - open val cacheSize get() = 1 + preloadAhead + preloadBehind + if (isDualPageMode()) 3 else 1 - - enum class PageState { - IDLE, - QUEUED, - LOADING, - DECODING, - } - - /** - * Evicts the page farthest from reference. Must be called while holding lock. - * - * Never evicts [reference], [keep], [currentPage] or what [pinnedFromPage] draws. Returns false - * when nothing was evictable, so a trim loop stops instead of spinning. - * - * @param reference The page to use as reference (defaults to currentPage) - * @param keep A page about to be handed out: evicted, it would be drawn destroyed. - */ - private fun evictFarthestPage(reference: ViewerPage? = null, keep: ViewerPage? = null): Boolean { - val current = reference ?: currentPage ?: return false - val candidates = pageCache.values - .filter { it !== current && it !== keep && it !== currentPage && !isPinned(it) } - .toMutableSet() - if (candidates.isEmpty()) return false - - // Read once - the getter measures the viewport. - val reach = cacheSize - - fun findNext(page: ViewerPage): ViewerPage? = when (page) { - is ViewerReaderPage -> { - val chapterId = page.page.chapter.chapter.id - val nextIndex = page.page.index + 1 - candidates.find { - it is ViewerReaderPage && it.page.chapter.chapter.id == chapterId && it.page.index == nextIndex - } ?: candidates.find { it is ViewerTransitionPage && it.prevChapter?.chapter?.id == chapterId } - ?: page.nextChapter?.chapter?.id?.let { nextChapterId -> - candidates.find { - it is ViewerReaderPage && it.page.chapter.chapter.id == nextChapterId && it.page.index == 0 - } - } - } - - is ViewerTransitionPage -> { - val nextChapterId = page.nextChapter?.chapter?.id - candidates.find { - it is ViewerReaderPage && it.page.chapter.chapter.id == nextChapterId && it.page.index == 0 - } - } - - else -> null - } - - fun findPrev(page: ViewerPage): ViewerPage? = when (page) { - is ViewerReaderPage -> { - val chapterId = page.page.chapter.chapter.id - val prevIndex = page.page.index - 1 - candidates.find { - it is ViewerReaderPage && it.page.chapter.chapter.id == chapterId && it.page.index == prevIndex - } ?: candidates.find { it is ViewerTransitionPage && it.nextChapter?.chapter?.id == chapterId } - ?: page.prevChapter?.let { prevChapter -> - prevChapter.pages?.lastIndex?.let { lastIndex -> - candidates.find { - it is ViewerReaderPage && it.page.chapter.chapter.id == prevChapter.chapter.id && - it.page.index == lastIndex - } - } - } - } - - is ViewerTransitionPage -> { - val prevChapterId = page.prevChapter?.chapter?.id - page.prevChapter?.pages?.lastIndex?.let { lastIndex -> - candidates.find { - it is ViewerReaderPage && it.page.chapter.chapter.id == prevChapterId && - it.page.index == lastIndex - } - } - } - - else -> null - } - - var farthest: ViewerPage? = null - var forward: ViewerPage? = current - var backward: ViewerPage? = current - - for (i in 0 until reach) { - if (candidates.isEmpty()) break - forward = forward?.let { findNext(it) } - backward = backward?.let { findPrev(it) } - if (forward == null && backward == null) break - if (forward != null && candidates.remove(forward)) farthest = forward - if (backward != null && candidates.remove(backward)) farthest = backward - } - - val toRemove = candidates.firstOrNull() ?: farthest ?: return false - - pageCache.remove(pageKey(toRemove)) - decodeQueue.remove(toRemove) - toRemove.state = PageState.IDLE - // Through the pin: a decode that swapped this page's image leaves its spread unguarded. - (toRemove as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage) - cleanupImage(toRemove.imagePage) - return true - } - - /** - * Gets or creates a page. Thread-safe. - * @param referencePage The page to use as reference for eviction (defaults to currentPage) - */ - fun getPage(page: ReaderPage, referencePage: ViewerPage? = null): ViewerPage { - val key = PageKey.Reader(page.chapter.chapter.id, page.index) - return synchronized(lock) { - findInCache(key) ?: ViewerReaderPage(page).also { newPage -> - pageCache[key] = newPage - val limit = cacheSize - while (pageCache.size > limit) { - if (!evictFarthestPage(referencePage ?: newPage, keep = newPage)) break - } - } - } - } - - fun getPage( - prevChapter: ReaderChapter?, - nextChapter: ReaderChapter?, - referencePage: ViewerPage? = null, - ): ViewerPage { - val key = PageKey.Transition(prevChapter?.chapter?.id, nextChapter?.chapter?.id) - return synchronized(lock) { - findInCache(key) ?: ViewerTransitionPage(prevChapter, nextChapter).also { newPage -> - pageCache[key] = newPage - val limit = cacheSize - while (pageCache.size > limit) { - if (!evictFarthestPage(referencePage ?: newPage, keep = newPage)) break - } - } - } - } - - /** - * Kicks off loading [chapter] and, once its pages actually show up, re-runs - * [preloadPages] from the current page - [ReaderActivity]'s viewModel.preload isn't - * guaranteed to have finished loading by the time it returns, so a single immediate - * retry can race it and silently never queue the adjacent chapter's edge page for - * decode. Gives up after 5 seconds if the chapter never finishes loading. - */ - private fun preloadChapterThenRetry(chapter: ReaderChapter) { - // fetchPage reaches prev/next per frame - unguarded, each frame starts another 5s poll. - val chapterId = chapter.chapter.id - synchronized(lock) { - if (!chapterPreloadsInFlight.add(chapterId)) return - } - - scope.launch(Dispatchers.IO) { - try { - activity.viewModel.preload(chapter) - repeat(25) { - if (chapter.state is ReaderChapter.State.Loaded) { - currentPage?.let { preloadPages(it) } - return@launch - } - delay(200.milliseconds) - } - } finally { - synchronized(lock) { chapterPreloadsInFlight.remove(chapterId) } - } - } - } - - inner class ErrorPage internal constructor( - message: String, - private val spreadPosition: SpreadPosition = SpreadPosition.SINGLE, - ) : ImagePage.Render(0, 0) { - override val width: Int - get() = viewportPageWidth(spreadPosition != SpreadPosition.SINGLE) - override val height: Int - get() = pager.state.height - - init { - minScale = 1f - maxScale = 1f - homeScale = 1f - } - - var message: String = message - set(value) { - field = value - invalidate() - } - - override val backgroundColor: Int = readerBackgroundColor() - - override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { - val padding = with(pager.state.density) { 24.dp.toPx() } - val size = scale * with(pager.state.density) { 16.dp.toPx() } - - val cx = dst.width * (0.5f + scale * x) - val cy = dst.height * (0.5f + scale * y) - - text( - dst, - activity.baseContext, - FontFamily.Default, - message, - cx, - cy, - size, - color = readerOnBackgroundColor(), - align = TextAlign.Center, - maxWidth = dst.width - 2f * padding, - ) - } - } - - inner class ProgressPage(foregroundColor: Int = readerOnBackgroundColor()) : ImagePage.Render(0, 0) { - override val width: Int - get() = viewportPageWidth(isDualPageMode()) - override val height: Int - get() = pager.state.height - - init { - minScale = 1f - maxScale = 1f - homeScale = 1f - } - - @Volatile - private var progressValue: Float = 0f - private var progressJob: Job? = null - var progress: Float - get() = progressValue - set(value) { - val target = value.fastCoerceIn(0f, 1f) - synchronized(this) { - progressJob?.cancel() - progressJob = null - - if (destroyed || progressValue == target) return - - val scope = scope ?: run { - progressValue = target - invalidate() - return - } - - val start = progressValue - progressJob = scope.launch { - animate( - start, - target, - animationSpec = spring(stiffness = Spring.StiffnessMediumLow), - ) { current, _ -> - progressValue = current - invalidate() - } - } - } - } - - override fun cleanup() { - super.cleanup() - synchronized(this) { - progressJob?.cancel() - progressJob = null - } - } - - var foregroundColor: Int = foregroundColor - set(value) { - field = value - invalidate() - } - - override val backgroundColor: Int = readerBackgroundColor() - - override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { - // Its own footprint, so the page carries its background wherever a transition puts it. - fillPage(dst, x, y, scale, backgroundColor) - - val cx = dst.width * (0.5f + scale * x) - val cy = dst.height * (0.5f + scale * y) - - val full = min(width, height) * 0.25f * scale - - circle(cx, cy, full / 2f, 0xAAAAAAAA.toInt()) - - val diameter = full * progress.fastCoerceIn(0f, 1f) - if (diameter > 0) { - circle(cx, cy, diameter / 2f, foregroundColor) - } - } - } - - inner class TransitionPage(val prevChapter: ReaderChapter?, val nextChapter: ReaderChapter?) : - ImagePage.Render(0, 0) { - /** Square, and never a spread side - [buildSpreadPage] hands it back whole. */ - override val width: Int - get() = min(pager.state.width, pager.state.height) - override val height: Int - get() = width - - init { - minScale = 1f - maxScale = 1f - homeScale = 1f - } - - override val backgroundColor: Int = readerBackgroundColor() - - override fun render(dst: GPUTexture, x: Float, y: Float, scale: Float) { - // Its own footprint, so the page carries its background wherever a transition puts it. - fillPage(dst, x, y, scale, backgroundColor) - - val lines: MutableList = mutableListOf() - prevChapter?.chapter?.let { chapter -> lines.add("Previous: " + chapter.name) } - nextChapter?.chapter?.let { chapter -> lines.add("Next: " + chapter.name) } - - val text = lines.joinToString("\n") - - val padding = with(pager.state.density) { 24.dp.toPx() } - val size = scale * with(pager.state.density) { 16.dp.toPx() } - - val cx = dst.width * (0.5f + scale * x) - val cy = dst.height * (0.5f + scale * y) - - text( - dst, - activity.baseContext, - FontFamily.Default, - text, - cx, - cy, - size, - readerOnBackgroundColor(), - align = TextAlign.Center, - maxWidth = dst.width - 2f * padding, - ) - } - } - - abstract class ViewerPage { - abstract val prevChapter: ReaderChapter? - abstract val nextChapter: ReaderChapter? - abstract val prev: ViewerPage? - abstract val next: ViewerPage? - - @Volatile - var state: PageState = PageState.IDLE - - @Volatile - open var imagePage: ImagePage = ImagePage.Dummy(400, 400) - - open val isDecoded = true - } - - inner class ViewerTransitionPage( - override val prevChapter: ReaderChapter?, - override val nextChapter: ReaderChapter?, - ) : ViewerPage() { - override var imagePage: ImagePage = TransitionPage(prevChapter, nextChapter) - - override val prev: ViewerPage? - get() = prevChapter?.pages?.lastOrNull()?.let { getPage(it, currentPage) } - - override val next: ViewerPage? - get() = nextChapter?.pages?.firstOrNull()?.let { getPage(it, currentPage) } - } - - inner class ViewerReaderPage(val page: ReaderPage) : ViewerPage() { - /** Cached spread ImagePage when this page is the anchor of a dual-page spread */ - var spreadPage: ImagePage.ImageSpread? = null - - /** The side the file names, or null for none. Never a value merely derived from the index. */ - @Volatile - internal var taggedSpreadPosition: SpreadPosition? = null - - /** The decoded image's shape, or null while this page is still a placeholder. */ - internal val aspectRatio: Float? - get() = (imagePage as? ImagePage.ImageSingle)?.let { - val height = it.trimHeight - if (it.isDecoded && height > 0) it.trimWidth.toFloat() / height else null - } - - /** - * Which half of a spread this page is on - derived until the file tags it. Without that a - * still-loading page stays SINGLE, never pairs, and its ring draws mid-screen; deriving it - * live also re-decides it on a rotation in or out of dual mode. - * - * Untagged goes by [wideAspect] first, then [derivedSpreadPosition]. - */ - internal val spreadPosition: SpreadPosition - get() { - taggedSpreadPosition?.let { return it } - if (standsAlone) return SpreadPosition.SINGLE - return derivedSpreadPosition(page) - } - - /** True when nothing may share this page's spread - it is one already. */ - internal val standsAlone: Boolean - get() = taggedSpreadPosition == SpreadPosition.SINGLE || (aspectRatio ?: 0f) > wideAspect - - override var imagePage: ImagePage = ProgressPage() - - /** So a download streams once; weak, as it holds the whole download. Under [lock]. */ - internal var streamedFrom: WeakReference? = null - - /** A streaming decode owns the page. Under [lock]. */ - internal var streaming = false - - /** Not counted by [isDecoded]. */ - @Volatile - internal var preview: ImagePage? = null - - override val isDecoded - get() = imagePage.let { it !== preview && (it as? ImagePage.ImageSingle)?.isDecoded == true } - - override val prevChapter: ReaderChapter? - get() = when (page.chapter) { - viewerChapters?.currChapter -> viewerChapters?.prevChapter - viewerChapters?.nextChapter -> viewerChapters?.currChapter - else -> null - } - - override val nextChapter: ReaderChapter? - get() = when (page.chapter) { - viewerChapters?.currChapter -> viewerChapters?.nextChapter - viewerChapters?.prevChapter -> viewerChapters?.currChapter - else -> null - } - - override val prev: ViewerPage? - get() = page.chapter.pages?.let { pages -> - pages.getOrNull(page.index - 1)?.let { getPage(it, currentPage) } ?: run { - val prevChapter = prevChapter ?: return@run getPage(null, page.chapter, currentPage) - - if (prevChapter.state !is ReaderChapter.State.Loaded) { - preloadChapterThenRetry(prevChapter) - } - - if (config.alwaysShowChapterTransition) { - getPage(prevChapter, page.chapter, currentPage) - } else { - prevChapter.pages?.lastOrNull()?.let { getPage(it, currentPage) } - } - } - } - - override val next: ViewerPage? - get() = page.chapter.pages?.let { pages -> - pages.getOrNull(page.index + 1)?.let { getPage(it, currentPage) } ?: run { - val nextChapter = nextChapter ?: return@run getPage(page.chapter, null, currentPage) - - if (nextChapter.state !is ReaderChapter.State.Loaded) { - preloadChapterThenRetry(nextChapter) - } - - if (config.alwaysShowChapterTransition) { - getPage(page.chapter, nextChapter, currentPage) - } else { - nextChapter.pages?.firstOrNull()?.let { getPage(it, currentPage) } - } - } - } - } - - /** Read live: these pages are built before the surface has a size, and outlive a rotation. */ - private fun viewportPageWidth(half: Boolean): Int = if (half) pager.state.width / 2 else pager.state.width - - /** - * Check if dual page mode is currently active based on config and view dimensions. - * Dual page is never active for continuous (scrolling) viewers. - */ - fun isDualPageMode(): Boolean { - if (isContinuous) return false - return when (config.dualPageView) { - ReaderPreferences.DualPageView.NEVER -> false - ReaderPreferences.DualPageView.ALWAYS -> true - ReaderPreferences.DualPageView.WIDE -> { - val width = pager.state.width - val height = pager.state.height - width > 0 && height > 0 && width.toFloat() / height > 1f - } - } - } - - /** The half a spread opens on: right reading right-to-left, left otherwise. */ - private val anchorPosition get() = if (isReversed) SpreadPosition.RIGHT else SpreadPosition.LEFT - - private val partnerPosition get() = if (isReversed) SpreadPosition.LEFT else SpreadPosition.RIGHT - - /** - * Which half a page falls on when nothing tags the file: alternating from its spread's start, - * anchor then partner. SINGLE outside dual page mode, so nothing pairs while one page fills - * the viewer. - */ - private fun derivedSpreadPosition(page: ReaderPage): SpreadPosition { - if (!isDualPageMode()) return SpreadPosition.SINGLE - val offset = page.index - spreadStartIndex(page.chapter.chapter.id, page.index) - return if (offset >= 0 && offset % 2 == 0) anchorPosition else partnerPosition - } - - /** - * Where the spread holding [index] starts: just past the last page before it that took one to - * itself, so the page after a detected spread opens the next one instead of inheriting a parity - * that page broke. Defaults to 1 - page 0 is the cover, and pairs with nothing. - */ - private fun spreadStartIndex(chapterId: Long?, index: Int): Int { - val lone = synchronized(lock) { loneIndices[chapterId]?.lower(index) } ?: return 1 - return lone + 1 - } - - /** Registers whether [page] stands alone, for [spreadStartIndex]. Must hold [lock]. */ - private fun noteIfLone(page: ViewerReaderPage) { - val indices = loneIndices.getOrPut(page.page.chapter.chapter.id) { TreeSet() } - if (page.standsAlone) indices.add(page.page.index) else indices.remove(page.page.index) - } - - /** - * Whether these two may share a spread, beyond their positions agreeing. Both tagged is taken - * as read; a pair resting on page order needs the same shape - halves of one sheet scan alike. - * Undecoded pairs anyway, or a loading page draws its ring mid-screen. - */ - private fun canPairShapes(anchor: ViewerReaderPage, partner: ViewerReaderPage): Boolean { - if (anchor.taggedSpreadPosition != null && partner.taggedSpreadPosition != null) return true - val a = anchor.aspectRatio ?: return true - val b = partner.aspectRatio ?: return true - return abs(a - b) <= pairAspectTolerance - } - - /** - * Get the anchor page for a spread. - * RTL: anchor is RIGHT, for LEFT page returns previous RIGHT - * LTR: anchor is LEFT, for RIGHT page returns previous LEFT - */ - private fun getSpreadAnchor(page: ViewerPage): ViewerPage { - if (!isDualPageMode()) return page - if (page !is ViewerReaderPage) return page - - if (page.spreadPosition == partnerPosition) { - val prev = page.prev as? ViewerReaderPage ?: return page - if (prev.page.chapter == page.page.chapter && prev.spreadPosition == anchorPosition && - canPairShapes(prev, page) - ) { - return prev - } - } - - return page - } - - /** Who [page] pairs with, or null. One verdict for [buildSpreadPage] and [progressPage]. */ - private fun spreadPartner(page: ViewerReaderPage): ViewerReaderPage? { - if (!isDualPageMode()) return null - if (page.spreadPosition != anchorPosition) return null - val next = (page.next as? ViewerReaderPage)?.takeIf { it.page.chapter == page.page.chapter } ?: return null - return next.takeIf { it.spreadPosition == partnerPosition && canPairShapes(page, it) } - } - - /** Page to report progress for - the spread's lastmost page, not the anchor. */ - private fun progressPage(page: ViewerPage): ViewerReaderPage? { - val readerPage = page as? ViewerReaderPage ?: return null - return spreadPartner(readerPage) ?: readerPage - } - - private fun buildSpreadPage(page: ViewerPage): ImagePage { - if (page !is ViewerReaderPage) { - return page.imagePage - } - - if (!isDualPageMode()) { - return page.imagePage - } - - // Whatever the page is holding takes its half of the seam, decoded or not: - // [ImagePage.ImageSpread] draws a [ImagePage.Render] side into its own half. A page left - // out would take the whole viewport instead, hiding its partner with it. - val imagePage = page.imagePage - - if (page.spreadPosition == SpreadPosition.SINGLE) { - page.spreadPage = null - return imagePage - } - - // Null for a partner reaching here directly, which means no anchor before it - a lone - // RIGHT at a chapter boundary - so it draws alone on its own side. - val partnerImagePage = spreadPartner(page)?.imagePage - - // LEFT/RIGHT map directly to the spread's left/right slot - independent of reading - // direction, which only decides which side is the anchor for pairing purposes above. - val left = if (page.spreadPosition == SpreadPosition.LEFT) imagePage else partnerImagePage - val right = if (page.spreadPosition == SpreadPosition.RIGHT) imagePage else partnerImagePage - - // Reuse existing spread if the sides match - preserves transform state - val existing = page.spreadPage - if (existing != null && existing.left === left && existing.right === right) { - return existing - } - - // Create new spread. Composes the existing page(s) directly, so either side (or both) - // keeps animating independently via its own already-running frame loop - no copying of - // animation state needed. The other slot is simply null when there's no partner (yet). - val spread = ImagePage.ImageSpread(left, right) - page.spreadPage = spread - return spread - } - - init { - pager.state.apply { - fetchPage = fetch@{ index -> - val current = currentPage ?: return@fetch null - - // For index 0, return the current spread - if (index == 0) { - return@fetch buildSpreadPage(getSpreadAnchor(current)) - } - - // Navigate by spreads from current - var page = current - val step = if (index > 0) 1 else -1 - repeat(abs(index)) { - page = nextPage(page, step) ?: return@fetch null - } - - return@fetch buildSpreadPage(page) - } - - onTap = { offset -> - when (config.navigator.getAction(PointF(offset.x, offset.y))) { - NavigationRegion.MENU -> activity.toggleMenu() - NavigationRegion.NEXT -> moveToNext() - NavigationRegion.PREV -> moveToPrevious() - NavigationRegion.RIGHT -> moveRight() - NavigationRegion.LEFT -> moveLeft() - } - } - - onLongTap = { _ -> - if (activity.viewModel.state.value.menuVisible || config.longTapEnabled) { - (currentPage as? ViewerReaderPage)?.let { activity.onPageLongTap(it.page) } - } - } - } - - config.imagePropertyChangedListener = { - // A theme change comes through here. - cachedBackgroundColor = null - cachedOnBackgroundColor = null - - val isDual = isDualPageMode() - pager.state.apply { - transition = when (if (isDual) config.transitionAnimationDual else config.transitionAnimation) { - TransitionAnimation.BASIC -> if (isVertical) TransitionBasic.Vertical else TransitionBasic - TransitionAnimation.FLIP -> TransitionFlip - TransitionAnimation.FLIP_LEFT -> TransitionFlipLeft - TransitionAnimation.FLIP_RIGHT -> TransitionFlipRight - TransitionAnimation.STACK_LEFT -> TransitionStackLeft - TransitionAnimation.STACK_RIGHT -> TransitionStackRight - TransitionAnimation.STACK_UP -> TransitionStackUp - TransitionAnimation.STACK_DOWN -> TransitionStackDown - TransitionAnimation.SPHERE -> TransitionSphere - TransitionAnimation.CUBE_INSIDE -> TransitionCube - TransitionAnimation.CUBE_OUTSIDE -> TransitionCubeOuter - TransitionAnimation.FADE -> TransitionFade - TransitionAnimation.FADE_WHITE -> TransitionFadeWhite - TransitionAnimation.NONE -> TransitionNone - } - - when (if (isDual) config.cutoutModeDual else config.cutoutMode) { - ReaderPreferences.CutoutMode.IGNORE -> avoidCutout = false - ReaderPreferences.CutoutMode.AVOID -> { - avoidCutout = true - alwaysAvoidCutout = false - } - - ReaderPreferences.CutoutMode.SHIFT -> { - avoidCutout = true - alwaysAvoidCutout = true - } - } - - (this as? ImageViewerContinuousState)?.let { - backgroundColor = readerBackgroundColor() - homeScale = config.continuousMinWidth / 100f - scale = homeScale - cropBorders = this@WebGpuViewer.cropBorders - minScale = if (config.zoomOutDisabled) 0f else 0.1f - - (this@WebGpuViewer as? WebGpuViewerContinuous)?.let { - if (this@WebGpuViewer.useGap) { - pageGap = config.continuousGap / 100f - } - } - } - } - - synchronized(lock) { - decodeQueue.clear() - loadJobs.values.forEach { it.cancel() } - loadJobs.clear() - watchJobs.values.forEach { it.cancel() } - watchJobs.clear() - pageCache.values.forEach { - it.state = PageState.IDLE - (it as? ViewerReaderPage)?.spreadPage?.let(::cleanupImage) - cleanupImage(it.imagePage) - } - pageCache.clear() - - currentPage = (currentPage as? ViewerReaderPage)?.page?.let { getPage(it) } - ?: (currentPage as? ViewerTransitionPage)?.let { - getPage(it.prevChapter, it.nextChapter) - } - - currentPage?.let { preloadPages(it) } - } - - pager.state.invalidate() - } - - config.navigationModeChangedListener = { - val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay - activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart) - } - } - - override fun destroy() { - // Before the interrupt: taken mid-decode, only the flag stops the worker parking. - destroyed = true - scope.cancel() - - // shutdownNow interrupts the worker out of lock.wait(). - decodeExecutor.shutdownNow() - decodeDispatcher.close() - streamExecutor.shutdownNow() - streamDispatcher.close() - - synchronized(lock) { - decodeQueue.clear() - // Nothing can still be animating, so the pin has nothing left to protect. - pinnedFromPage = null - pageCache.values.forEach { - it.state = PageState.IDLE - (it as? ViewerReaderPage)?.spreadPage?.cleanup() - it.imagePage.cleanup() - } - pageCache.clear() - deferredCleanup.forEach { it.cleanup() } - deferredCleanup.clear() - loneIndices.clear() - chapterPreloadsInFlight.clear() - lock.notifyAll() - } - } - - override fun getView(): View = pager - - private val cropBorders: Boolean - get() = if (isContinuous) config.imageCropBordersWebtoon else config.imageCropBorders - - /** - * Asks the loader for [page], after whatever it was asked for before; a request replaces - * the page's last one. Cancelling it drops the download unless it has started. - */ - private fun requestDownload(page: ViewerReaderPage) { - val loader = page.page.chapter.pageLoader ?: return - if (page.page.downloadStream == null) page.page.downloadStream = DownloadStream() - synchronized(lock) { - loadJobs.remove(pageKey(page))?.cancel() - // Undispatched for the HTTP loader, which queues before suspending, so requests queue in - // order. Other loaders may work first, which mustn't happen here under the lock. - val start = if (loader is HttpPageLoader) CoroutineStart.UNDISPATCHED else CoroutineStart.DEFAULT - loadJobs[pageKey(page)] = scope.launch(Dispatchers.IO, start = start) { - loader.loadPage(page.page) - } - } - } - - /** Downloads [page] if needed, then re-queues it for decode once ready. */ - private fun startPageLoad(page: ViewerReaderPage) { - val loader = page.page.chapter.pageLoader ?: run { - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } - return - } - - if (page.page.status == Page.State.Ready) { - synchronized(lock) { - if (pageInCache(page) && !page.isDecoded && !page.streaming) { - page.state = PageState.IDLE - queueForDecode(page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false) - } else if (pageInCache(page)) { - page.state = PageState.IDLE - } - } - return - } - - synchronized(lock) { - if (!pageInCache(page)) return - page.state = PageState.LOADING - } - - if (page.page.status == Page.State.Queue) { - requestDownload(page) - } - - val watch = scope.launch(start = CoroutineStart.LAZY) { - try { - val downloadProgressJob = launch { - page.page.progressFlow.collect { value -> - // Set under the lookup's lock, or an eviction's cleanup() lands between. - synchronized(lock) { - if (!pageInCache(page)) return@collect - (page.imagePage as? ProgressPage)?.progress = value / 100f - } - } - } - - page.page.statusFlow.takeWhile { state -> - // Evicted: stop watching, rather than holding the page until the download ends. - if (!synchronized(lock) { pageInCache(page) }) return@takeWhile false - - when (state) { - Page.State.Queue, Page.State.LoadPage -> true - Page.State.DownloadImage -> { - page.page.downloadStream?.let { startStreamDecode(page, it) } - true - } - - is Page.State.Error -> { - logcat(LogPriority.ERROR) { "Page load error: ${state.error}" } - false - } - - Page.State.Ready -> false - } - }.collect {} - - downloadProgressJob.cancel() - - synchronized(lock) { - if (pageInCache(page) && page.state == PageState.LOADING) { - page.state = PageState.IDLE - if (page.page.status == Page.State.Ready && !page.isDecoded && !page.streaming) { - queueForDecode( - page, - prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false, - ) - } - } - } - } catch (e: CancellationException) { - // Reset or destroy; whoever cancelled has already put the state right. - throw e - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "startPageLoad error" } - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } - } finally { - val self = coroutineContext[Job] - synchronized(lock) { - if (watchJobs[pageKey(page)] === self) watchJobs.remove(pageKey(page)) - } - } - } - synchronized(lock) { - watchJobs.remove(pageKey(page))?.cancel() - watchJobs[pageKey(page)] = watch - } - watch.start() - } - - private suspend fun decodeReaderPage(page: ViewerReaderPage) { - if (page.page.status != Page.State.Ready) { - startPageLoad(page) - return - } - - val stream = page.page.stream?.invoke() ?: run { - synchronized(lock) { if (pageInCache(page)) page.state = PageState.IDLE } - return - } - - stream.use { input -> - synchronized(lock) { - if (!pageInCache(page) || page.isDecoded || page.streaming) { - if (pageInCache(page)) page.state = PageState.IDLE - return - } - } - - logcat(LogPriority.INFO) { "Decoding ${pageName(page)}" } - val started = SystemClock.uptimeMillis() - var done = false - try { - buildPage(page, ImageDecoder.open(input), streaming = false) { false } - done = true - } finally { - val ms = SystemClock.uptimeMillis() - started - logcat(LogPriority.INFO) { - "${if (done) "Decoded" else "Failed decoding"} ${pageName(page)} in $ms ms" - } - } - } - } - - private fun pageName(page: ViewerReaderPage): String { - val p = page.page - return p.imageUrl?.takeIf { it.isNotEmpty() } - ?: p.url.takeIf { it.isNotEmpty() } - ?: "${p.chapter.chapter.name} page ${p.index + 1}" - } - - /** Decodes [page] while it downloads; on failure the whole-file decode takes over. */ - private fun startStreamDecode(page: ViewerReaderPage, live: DownloadStream) { - synchronized(lock) { - if (!pageInCache(page) || page.isDecoded || page.streamedFrom?.get() === live) return - page.streamedFrom = WeakReference(live) - } - scope.launch(streamDispatcher) { - val claimed = synchronized(lock) { - val ok = pageInCache(page) && !page.isDecoded && page.page.status == Page.State.DownloadImage - if (ok) page.streaming = true - ok - } - if (!claimed) return@launch - // Paced by the download, so this is mostly how long that took. - val started = SystemClock.uptimeMillis() - try { - live.reader().use { reader -> - // available() 0 stops the read after the header. - val headerOnly = object : FilterInputStream(reader) { - override fun available() = 0 - } - logcat(LogPriority.INFO) { "Decoding while downloading ${pageName(page)}" } - val chunk = ByteArray(STREAM_CHUNK) - ImageDecoder.open(headerOnly, complete = false).let { dec -> - var fed = false - var fedAt = 0L - // Blocks for new bytes: decoding again on none only repeats work. - buildPage(page, dec, streaming = true) { - if (fed) return@buildPage false - while (reader.available() == 0 && !reader.ended) { - if (!synchronized(lock) { pageInCache(page) }) return@buildPage false - reader.await(1, STREAM_WAIT_MS) - } - val gap = fedAt + STREAM_BATCH_MS - SystemClock.uptimeMillis() - if (gap > 0) reader.await(Int.MAX_VALUE, gap) - while (reader.available() > 0) dec.pushData(chunk, 0, reader.read(chunk)) - fedAt = SystemClock.uptimeMillis() - if (reader.ended) { - dec.markComplete() - fed = true - } - true - } - } - } - logcat(LogPriority.INFO) { - "Decoded while downloading ${pageName(page)} in ${SystemClock.uptimeMillis() - started} ms" - } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - logcat(LogPriority.DEBUG) { - "Streaming decode gave up after ${SystemClock.uptimeMillis() - started} ms: ${e.message}" - } - } catch (e: OutOfMemoryError) { - logcat(LogPriority.WARN) { "Streaming decode ran out of memory" } - } finally { - synchronized(lock) { - page.streaming = false - // Failed download: back to the ring for a retry. - val preview = page.preview - if (pageInCache(page) && page.imagePage === preview && page.page.status is Page.State.Error) { - page.preview = null - page.imagePage = ProgressPage() - cleanupImage(preview) - pager.state.invalidate() - } - if (pageInCache(page) && !page.isDecoded && page.state == PageState.IDLE && - page.page.status == Page.State.Ready - ) { - queueForDecode(page, prioritize = currentPage?.let { pageKey(it) == pageKey(page) } ?: false) - } - } - } - } - } - - /** - * Decodes [dec] into [page], then closes it - unless an animation keeps it, to decode frames - * past the first as they come due. [feed] pushes more bytes, false once all are in. - */ - private suspend fun buildPage( - page: ViewerReaderPage, - dec: ImageDecoder, - streaming: Boolean, - feed: () -> Boolean, - ) { - var kept = false - try { - decodeInto(page, dec, streaming, feed) { kept = true } - } finally { - if (!kept) dec.close() - } - } - - private suspend fun decodeInto( - page: ViewerReaderPage, - dec: ImageDecoder, - streaming: Boolean, - feed: () -> Boolean, - keep: () -> Unit, - ) { - // The decoder hands the map over unapplied - see ImageDecoder.Gainmap - because how - // much of it to use depends on the display, so the viewer applies it. - fun ImageDecoder.Gainmap.toInput() = let { - GainmapInput( - pixels = it.pixels, - width = it.width, - height = it.height, - channels = it.channels, - gamma = it.gamma, - minContentBoost = it.minContentBoost, - maxContentBoost = it.maxContentBoost, - offsetSdr = it.offsetSdr, - offsetHdr = it.offsetHdr, - ) - } - - if (isDualPageMode()) { - page.taggedSpreadPosition = when (dec.getTag("PageName")) { - "Left" -> SpreadPosition.LEFT - "Right" -> SpreadPosition.RIGHT - null -> null - else -> SpreadPosition.SINGLE - } - } - - val backgroundColor = if (config.automaticBackground) null else readerBackgroundColor() - - // SDR only: HDR skips Image's tone mapping; a gain map may need float textures. - var previewing = streaming && !dec.isHdr && dec.hdrKind != ImageDecoder.HdrKind.GAINMAP - - // Null once the page is unwanted. - suspend fun nextWhole(onPartial: (suspend (ImageDecoder.Frame) -> Unit)? = null): ImageDecoder.Frame? { - while (true) { - if (!synchronized(lock) { pageInCache(page) }) return null - val f = try { - dec.decodeNext() - } catch (e: ImageDecoder.NeedMoreDataException) { - if (!feed()) throw e - continue - } - if (!f.partial) return f - // A partial views the decoder's canvas, valid only until the next step. - f.use { if (onPartial != null && previewing) onPartial(it) } - feed() - } - } - - var preview: ImagePage.ImageSingle? = null - - // Replaces the placeholder, then updates its texture in place. - suspend fun showPartial(f: ImageDecoder.Frame) { - if (!previewing || !f.changed) return - val shown = preview - if (shown == null) { - val single = ImagePage.ImageSingle( - Image( - f.image, - f.width, - f.height, - createMipMaps = false, - backgroundColor = backgroundColor ?: readerBackgroundColor(), - ), - ).apply { - // The tile cache would go stale. - highQuality = false - } - val installed = synchronized(lock) { - val old = page.imagePage - (pageInCache(page) && old is ProgressPage && !old.destroyed).also { ok -> - if (!ok) return@also - page.preview = single - page.imagePage = single - cleanupImage(old) - if (old.isOnScreen) single.fadeIn() - if (!isDualPageMode() && !applyWideZoomIfNeeded(single)) applyFitModeAnchor(single) - pager.state.invalidate() - } - } - if (installed) preview = single else single.cleanup() - previewing = installed - } else { - // Replaced, as by an error page. - if (synchronized(lock) { page.imagePage !== shown } || shown.destroyed) { - previewing = false - return - } - val rect = Rect(f.dirtyX, f.dirtyY, f.dirtyX + f.dirtyWidth, f.dirtyY + f.dirtyHeight) - shown.image?.update(f.image, rect) - shown.invalidate() - // A spread's page isn't on screen itself. - pager.state.invalidate() - } - } - - // Nothing to preview: the whole-file decode after the download does as well, - // without holding the one streaming thread through it. - if (streaming && !previewing) return - val firstFrame = nextWhole(onPartial = { showPartial(it) }) ?: return - var secondFrame: ImageDecoder.Frame? = null - // A new image until a page takes it. - var orphan: Image? = null - // Whole frames hold native pixels until closed, on every exit. - try { - // Streamed, a still's end and gain map are only known once all is in. - if (streaming) { - while (feed()) { - if (!synchronized(lock) { pageInCache(page) }) return - } - if (dec.hasNext) { - secondFrame = try { - nextWhole() ?: return - } catch (e: ImageDecoder.DecodeException) { - logcat(LogPriority.DEBUG) { "Treating as a still: ${e.message}" } - null - } - } - } - val animated = if (streaming) secondFrame != null else dec.hasNext - - // A gain map belongs to a still; its headroom is the map's, not the decoder's. - val gainmap = if (!animated && dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) { - dec.getGainmap() - } else { - null - } - val hdrHeadroom = gainmap?.headroomStops ?: dec.hdrHeadroom - - // Only trim when not animated and not in dual page mode - val trimColors = if (!animated && cropBorders && !isDualPageMode()) { - listOf( - floatArrayOf(1f, 1f, 1f), - floatArrayOf(0f, 0f, 0f), - ) - } else { - null - } - - // The preview's textures become the image, or frame 0; one since replaced or freed - // (a settings reset) leaves a fresh image to install instead. - val shown = preview?.takeIf { synchronized(lock) { page.imagePage === it && !it.destroyed } } - // Only a page the reader hasn't moved re-homes for its trim. - val wasHome = shown?.atHome == true - val firstImage = shown?.image?.also { - it.update(firstFrame.image) - if (!animated) it.createMipMaps(firstFrame.image) - it.measure(firstFrame.image, trimColors, trimThreshold = 0.15f, backgroundColor = backgroundColor) - } ?: Image( - firstFrame.image, - firstFrame.width, - firstFrame.height, - createMipMaps = !animated, - trimColors = trimColors, - trimThreshold = 0.15f, - backgroundColor = backgroundColor, - hdr = dec.isHdr, - hdrHeadroom = hdrHeadroom, - gainmap = gainmap?.toInput(), - ).also { orphan = it } - val firstDuration = firstFrame.duration - firstFrame.close() - - synchronized(lock) { - val target = when { - // Evicted. - !pageInCache(page) -> null - - // The preview becomes the image in place, keeping pan and zoom - unless - // something replaced it meanwhile. - shown != null -> shown.takeIf { page.imagePage === it && !it.destroyed }?.also { - page.preview = null - // Tiles for a still; an animation swaps images every frame. - it.highQuality = !animated - if (wasHome && firstImage.trim != null && !isDualPageMode()) rehome(it) - // Bumps frameVersion, so a transition's cached render of the preview is stale. - it.invalidate() - } - - // A new page in the placeholder's place. - !page.isDecoded && !page.imagePage.destroyed -> { - val imagePage = ImagePage.ImageSingle(firstImage) - orphan = null - val old = page.imagePage - // A failed stream's preview is already showing. - val fromPreview = old === page.preview - page.imagePage = imagePage - page.preview = null - cleanupImage(old) - // Fade up from the placeholder's colour, if that placeholder was on screen - - // one that decoded out of view has nothing left to fade from. - if (old.isOnScreen && !fromPreview) imagePage.fadeIn() - if (!isDualPageMode() && !applyWideZoomIfNeeded(imagePage)) { - applyFitModeAnchor(imagePage) - } - imagePage - } - - else -> null - } - if (pageInCache(page)) page.state = PageState.IDLE - target?.also { installedPage -> - if (animated) { - // The page takes the decoder, which loops, and the second frame decoded - // to tell it's animated, which goes first: the rest decode as they're due. - keep() - var ahead = secondFrame - secondFrame = null - val name = pageName(page) - val release = { - ahead?.close() - ahead = null - dec.close() - } - installedPage.animate(firstDuration, release) { - withContext(animationDispatcher) { - val f = ahead?.also { ahead = null } ?: if (dec.hasNext) { - val started = SystemClock.uptimeMillis() - dec.decodeNext().also { - logcat(LogPriority.DEBUG) { - "Decoded frame ${it.index} of $name in " + - "${SystemClock.uptimeMillis() - started} ms" - } - } - } else { - null - } - f?.let { AnimationFrame(it.image, it.duration) { it.close() } } - } - } - } - noteIfLone(page) - pager.state.invalidate() - } - } - } finally { - firstFrame.close() - secondFrame?.close() - // Nothing took it, normally or not. A preview's image belongs to its page either way. - orphan?.let { ImagePage.ImageSingle(it).cleanup() } - } - } - - /** Animates [page] to where a fresh install would place it, e.g. once its trim is known. */ - private fun rehome(page: ImagePage.ImageSingle) { - val fromX = page.x - val fromY = page.y - val fromScale = page.scale - // Cached from the old size. - page.homeScale = -1f - page.homeX = 0f - page.homeY = 0f - page.minScale = 0f - // The placement a fresh install gets, read back and undone so it can animate there. - page.x = page.homeX - page.y = page.homeY - page.scale = page.homeScale - if (!applyWideZoomIfNeeded(page)) applyFitModeAnchor(page) - val toX = page.x - val toY = page.y - val toScale = page.scale - page.x = fromX - page.y = fromY - page.scale = fromScale - page.animateTo(targetX = toX, targetY = toY, targetScale = toScale) - } - - private fun applyWideZoomIfNeeded(page: ImagePage.ImageSingle): Boolean { - if (!config.landscapeZoom) return false - - val screenW = pager.state.width - val screenH = pager.state.viewportHeight - if (screenW <= 0 || screenH <= 0) return false - - // don't zoom if it fits at original scale - if (page.trimWidth <= screenW) return false - - val image = page.image ?: return false - - val aspectRatio = min( - page.trimWidth.toFloat() / page.trimHeight.toFloat(), - image.width.toFloat() / image.height.toFloat(), - ) - - // not wide enough - if (aspectRatio < 1.1) return false - - // Wide page: half of it is wider than the screen aspect ratio or matches a decoded neighbour's shape - val isWide = listOf(viewerPageFor(page)?.prev, viewerPageFor(page)?.next) - .mapNotNull { (it as? ViewerReaderPage)?.aspectRatio } - .any { abs(aspectRatio / 2f - it) <= pairAspectTolerance } || - (aspectRatio > 2f * screenW.toFloat() / screenH) - if (!isWide) return false - - page.parent = pager.state - - // Half the image width fills the screen width. - page.homeScale = screenW.toFloat() / (page.trimWidth / 2f) - - page.scale = page.homeScale - - val minX = page.minX(page.homeScale) - val maxX = page.maxX(page.homeScale) - - page.x = when (config.imageZoomType) { - ZoomStartPosition.LEFT -> maxX - ZoomStartPosition.RIGHT -> minX - ZoomStartPosition.CENTER -> 0f - } - - page.y = page.homeY - - return true - } - - private fun applyFitModeAnchor(page: ImagePage.ImageSingle) { - val scaleType = config.imageScaleType - if (scaleType != 3 && scaleType != 4 && scaleType != 5) return - - val screenW = pager.state.width - val screenH = pager.state.viewportHeight - if (screenW <= 0 || screenH <= 0) return - - val w = page.trimWidth.toFloat() - val h = page.trimHeight.toFloat() - if (w <= 0f || h <= 0f) return - - page.parent = pager.state - - page.homeScale = when (scaleType) { - 3 -> screenW / w - 4 -> screenH / h - else -> 1f // original size - }.coerceAtLeast(0.01f) - - page.scale = page.homeScale - - if (scaleType == 5) { // original size - if (page.homeScale < page.minScale) { - page.minScale = page.homeScale - } - } - - val minX = page.minX(page.homeScale) - val maxX = page.maxX(page.homeScale) - - page.x = when (config.imageZoomType) { - ZoomStartPosition.LEFT -> maxX - ZoomStartPosition.RIGHT -> minX - ZoomStartPosition.CENTER -> 0f - } - - page.y = page.homeY - } - - protected fun preloadPage(page: ViewerPage, prioritize: Boolean = false) { - synchronized(lock) { - val cachedPage = findInCache(pageKey(page)) ?: return - if (cachedPage is ViewerReaderPage) { - queueForDecode(cachedPage, prioritize) - } - } - } - - /** - * Drops decodes outside [window], and every download not yet started so that [preloadPages] - * asks again in order. A download already running finishes; its page decodes if it's back - * in the window by then. - */ - private fun resetQueues(window: Set) { - synchronized(lock) { - val stale = decodeQueue.filter { pageKey(it) !in window } - decodeQueue.removeAll(stale.toSet()) - stale.forEach { if (it.state == PageState.QUEUED) it.state = PageState.IDLE } - - // All of them: what stays wanted is asked again, in this window's order. - val iterator = loadJobs.entries.iterator() - while (iterator.hasNext()) { - val (key, job) = iterator.next() - job.cancel() - iterator.remove() - if (key in window) continue - // Never started: its watcher goes too. One downloading keeps it, to decode when done. - (pageCache[key] as? ViewerReaderPage)?.let { - if (it.state == PageState.LOADING && it.page.status == Page.State.Queue) { - watchJobs.remove(key)?.cancel() - it.state = PageState.IDLE - } - } - } - } - } - - /** - * Starts [page]'s download now, rather than when the one decode worker reaches it - which a - * slow decode ahead of it holds up, and which left pages past the loader's own preload - * waiting. Ready then queues the decode. - */ - private fun startDownload(page: ViewerPage) { - synchronized(lock) { - val cachedPage = findInCache(pageKey(page)) as? ViewerReaderPage ?: return - if (cachedPage.page.status == Page.State.Ready || cachedPage.isDecoded) return - when (cachedPage.state) { - // Watched already: only re-request, in this order. - PageState.LOADING -> { - if (cachedPage.page.status == Page.State.Queue) requestDownload(cachedPage) - return - } - - PageState.DECODING -> return - // Ready requeues it, once downloaded. - PageState.QUEUED -> decodeQueue.remove(cachedPage) - PageState.IDLE -> {} - } - cachedPage.state = PageState.IDLE - startPageLoad(cachedPage) - } - } - - protected fun preloadPages(page: ViewerPage) { - // page may be a stale copy - resolve the live cache entry. - val key = pageKey(page) - val cachedPage = synchronized(lock) { findInCache(key) } ?: return - - // prev, then next, then current+partner - the last prioritized call ends up highest. - val prevPages = mutableListOf() - var p: ViewerPage? = cachedPage - for (i in 0 until preloadBehind) { - p = p?.prev ?: break - prevPages.add(p) - } - prevPages.asReversed().forEach { preloadPage(it) } - - val nextPages = mutableListOf() - p = cachedPage - for (i in 0 until preloadAhead) { - p = p?.next ?: break - nextPages.add(p) - } - resetQueues((prevPages + cachedPage + nextPages).mapTo(HashSet()) { pageKey(it) }) - - // Downloads in reading order, as the loader serves requests of equal priority. - startDownload(cachedPage) - nextPages.forEach { startDownload(it) } - prevPages.forEach { startDownload(it) } - - nextPages.asReversed().forEach { preloadPage(it) } - - cachedPage.next?.let { preloadPage(it, prioritize = true) } - preloadPage(cachedPage, prioritize = true) - } - - /** - * Tells this viewer to set the given [chapters] as active. If the pager is currently idle, - * it sets the chapters immediately, otherwise they are saved and set when it becomes idle. - */ - override fun setChapters(chapters: ViewerChapters) { - // Empty too: lastIndex would be -1, and the requested page is read from it. - val pages = chapters.currChapter.pages - if (pages.isNullOrEmpty()) return - - this.viewerChapters = chapters - - // Only when nothing shows yet - re-setting chapters must not move the page. - val page = currentPage ?: getPage(pages[min(chapters.currChapter.requestedPage, pages.lastIndex)]) - val anchor = getSpreadAnchor(page) - currentPage = anchor - progressPage(anchor)?.let { activity.onPageSelected(it.page) } - preloadPages(anchor) - - pager.state.apply { - onPageChange = onPageChange@{ delta -> - // The viewer already showed the page at fetchPage(delta). - // We need to update currentPage to match that. - val current = currentPage ?: return@onPageChange - - // Navigate the same way fetchPage does - var page = current - val step = if (delta > 0) 1 else -1 - repeat(abs(delta)) { - page = nextPage(page, step) ?: return@onPageChange - } - - // Synchronous, since the viewer walks getPage() from here - stale, and the next - // scroll step crosses the same boundary again. - currentPage = page - - // The rest ran here too, on the animation thread under the viewer's scroll lock. - // Posted in order, so nothing is skipped or reordered - and on this viewer's own - // MainScope, not the state's: that one dispatches inside the frame callback. - val settled = page - this@WebGpuViewer.scope.launch { - if (!isContinuous) { - if (!activity.isScrollingThroughPages) { - activity.hideMenu() - } - progressPage(settled)?.let { activity.onPageSelected(it.page) } - } - preloadPages(settled) - - if (!isContinuous) { - (settled as? ViewerTransitionPage)?.let { transitionPage -> - if (transitionPage.prevChapter == null || transitionPage.nextChapter == null) { - activity.showMenu() - } - } - } - } - } - - invalidate() - } - } - - /** - * Tells this viewer to move to the given [page]. - * In dual page mode, aligns to the start of the spread containing the page. - */ - override fun moveToPage(page: ReaderPage) { - // Pin first: resolving a target outside the cached window trims the cache. - pinnedFromPage = currentPage?.let { buildSpreadPage(it) } - moveToPage(getSpreadAnchor(getPage(page))) - } - - private fun moveToPage(newPage: ViewerPage) { - val previousPage = currentPage - // Before preloadPages below trims the cache - see [pinnedFromPage]. - val fromSpread = previousPage?.let { buildSpreadPage(it) } - pinnedFromPage = fromSpread - synchronized(lock) { flushDeferredCleanup() } - - currentPage = newPage - progressPage(newPage)?.let { activity.onPageSelected(it.page) } - preloadPages(newPage) - - (newPage as? ViewerTransitionPage)?.let { ViewerTransitionPage -> - if (ViewerTransitionPage.prevChapter == null || ViewerTransitionPage.nextChapter == null) { - activity.showMenu() - } - } - - if (previousPage == null) return - - val direction = when (previousPage) { - is ViewerReaderPage if newPage is ViewerReaderPage -> if (previousPage.page.chapter == - newPage.page.chapter - ) { - (newPage.page.index - previousPage.page.index).coerceIn(-1, 1) - } else if (previousPage.page.chapter == newPage.prevChapter) { - 1 - } else { - -1 - } - - is ViewerTransitionPage if newPage is ViewerReaderPage -> if (previousPage.nextChapter == - newPage.page.chapter - ) { - 1 - } else { - -1 - } - - is ViewerReaderPage if newPage is ViewerTransitionPage -> if (previousPage.page.chapter == - newPage.prevChapter - ) { - 1 - } else { - -1 - } - - else -> 0 - } - - if (direction != 0 && fromSpread != null) { - animateTurn(direction, fromSpread) - } else { - pager.state.invalidate() - } - } - - /** How a [moveToPage] turn is shown. [direction] is 1 forward through the pages, -1 back. */ - protected open fun animateTurn(direction: Int, fromSpread: ImagePage) { - pager.state.transitionFromPage = fromSpread - pager.state.animatePageTurn(if (isReversed) direction else -direction) - } - - // moveRight/moveLeft are screen directions, so a right-to-left book's next is to the left. - fun moveToNext() { - if (isReversed) moveLeft() else moveRight() - } - - fun moveToPrevious() { - if (isReversed) moveRight() else moveLeft() - } - - protected open fun moveRight() { - pager.state.getPage(0)?.let { page -> - if (config.navigateToPan) { - val minX = page.minX(page.scale) - val maxX = page.maxX(page.scale) - // Where a running pan is headed, else where it sits. - val currentX = page.animationTargetX ?: page.x - - val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1 - val x = (currentX - c / page.scale).coerceIn(minX, maxX) - - if (!currentX.closeTo(x)) { - page.animateTo(targetX = x, targetY = page.y) - return - } - } - - navigateSpread(if (isReversed) -1 else 1) - } - } - - protected open fun moveLeft() { - pager.state.getPage(0)?.let { page -> - if (config.navigateToPan) { - val minX = page.minX(page.scale) - val maxX = page.maxX(page.scale) - val currentX = page.animationTargetX ?: page.x - - val c = if (isVertical && config.imageZoomType == ZoomStartPosition.RIGHT) -1 else 1 - val x = (currentX + c / page.scale).coerceIn(minX, maxX) - - if (!currentX.closeTo(x)) { - page.animateTo(targetX = x, targetY = page.y) - return - } - } - - navigateSpread(if (isReversed) 1 else -1) - } - } - - /** Target anchor page one spread past [from], in [direction] (positive = forward). */ - private fun nextPage(from: ViewerPage, direction: Int): ViewerPage? { - var page = getSpreadAnchor(from) - - page = if (direction > 0) { - if (page is ViewerReaderPage && spreadPartner(page) != null) { - page.next?.next ?: return null - } else { - page.next ?: return null - } - } else { - page.prev ?: return null - } - - return getSpreadAnchor(page) - } - - private fun navigateSpread(direction: Int) { - val target = currentPage?.let { nextPage(it, direction) } ?: return - moveToPage(target) - } - - protected fun moveUp() { - moveToPrevious() - } - - protected fun moveDown() { - moveToNext() - } - - /** - * Called from the containing activity when a key [event] is received. It should return true - * if the event was handled, false otherwise. - */ - override fun handleKeyEvent(event: KeyEvent): Boolean { - val isUp = event.action == KeyEvent.ACTION_UP - val ctrlPressed = event.metaState.and(KeyEvent.META_CTRL_ON) > 0 - when (event.keyCode) { - KeyEvent.KEYCODE_VOLUME_DOWN -> { - if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { - return false - } else if (isUp) { - if (!config.volumeKeysInverted) moveDown() else moveUp() - } - } - - KeyEvent.KEYCODE_VOLUME_UP -> { - if (!config.volumeKeysEnabled || activity.viewModel.state.value.menuVisible) { - return false - } else if (isUp) { - if (!config.volumeKeysInverted) moveUp() else moveDown() - } - } - - KeyEvent.KEYCODE_DPAD_RIGHT -> if (isUp) if (ctrlPressed) moveToNext() else moveRight() - KeyEvent.KEYCODE_DPAD_LEFT -> if (isUp) if (ctrlPressed) moveToPrevious() else moveLeft() - KeyEvent.KEYCODE_DPAD_DOWN -> if (isUp) moveDown() - KeyEvent.KEYCODE_DPAD_UP -> if (isUp) moveUp() - KeyEvent.KEYCODE_PAGE_DOWN -> if (isUp) moveDown() - KeyEvent.KEYCODE_PAGE_UP -> if (isUp) moveUp() - KeyEvent.KEYCODE_MENU -> if (isUp) activity.toggleMenu() - else -> return false - } - return true - } - - /** - * Called from the containing activity when a generic motion [event] is received. It should - * return true if the event was handled, false otherwise. - */ - override fun handleGenericMotionEvent(event: MotionEvent): Boolean { - if (event.source and InputDevice.SOURCE_CLASS_POINTER != 0) { - when (event.action) { - MotionEvent.ACTION_SCROLL -> { - if (event.getAxisValue(MotionEvent.AXIS_VSCROLL) < 0.0f) { - moveDown() - } else { - moveUp() - } - return true - } - } - } - return false - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt deleted file mode 100644 index 2c1ea8be8..000000000 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/viewer/webgpu/WebGpuViewerContinuous.kt +++ /dev/null @@ -1,102 +0,0 @@ -package eu.kanade.tachiyomi.ui.reader.viewer.webgpu - -import ca.mpreg.webgpuviewer.ImageViewContinuous -import ca.mpreg.webgpuviewer.viewer.ImagePage -import eu.kanade.tachiyomi.ui.reader.ReaderActivity -import eu.kanade.tachiyomi.ui.reader.model.ReaderPage -import kotlinx.coroutines.launch -import kotlin.math.max - -class WebGpuViewerContinuous(activity: ReaderActivity, val useGap: Boolean = false) : - WebGpuViewer(activity, isReversed = false, isVertical = true, pager = ImageViewContinuous(activity)) { - - override val isContinuous: Boolean get() = true - - // How many pages the viewport shows depends on the zoom, and a page on screen has to be - // decoded rather than merely reserved - so the window follows what the last frame reached. - override val preloadAhead get() = max(3, state.pagesBelow) - override val preloadBehind get() = max(2, state.pagesAbove) - - override val cacheSize get() = 2 + 2 * max(4, state.pagesAbove + state.pagesBelow) - - private val state get() = (pager as ImageViewContinuous).state - - init { - state.backgroundColor = readerBackgroundColor() - - state.onViewport = { readThrough -> - val pageChanged = readThrough !== lastReadThrough - if (pageChanged) { - lastReadThrough = readThrough - readThrough?.let { select(it) } - } - - val page0 = state.getPage(0) - val edge = (page0 is TransitionPage && page0.prevChapter == null) || - (readThrough is TransitionPage && readThrough.nextChapter == null) - - val first = wasAtEdge == null - val edgeChanged = edge != wasAtEdge - wasAtEdge = edge - if (edge) { - if (edgeChanged) scope.launch { activity.showMenu() } - } else if (!first && (edgeChanged || pageChanged) && !activity.isScrollingThroughPages) { - scope.launch { activity.hideMenu() } - } - } - } - - /** Null until the first frame, so opening mid-document neither shows nor hides. */ - @Volatile - private var wasAtEdge: Boolean? = null - - @Volatile - private var lastReadThrough: ImagePage? = null - - /** - * Mark [imagePage] read - its bottom has cleared the viewport, which is later than - * onPageChange's "top arrived" and the reason this mode selects from here: scrolling up to - * reveal the page above would otherwise walk progress back past pages still on screen. - */ - private fun select(imagePage: ImagePage) { - val page = readerPageFor(imagePage) ?: return - scope.launch { activity.onPageSelected(page) } - } - - private fun readerPageFor(imagePage: ImagePage): ReaderPage? { - // Off the page itself: a transition page is never currentPage (the scroll walk stops - // before it), so it is the first thing a preload evicts. - if (imagePage is TransitionPage) return imagePage.prevChapter?.pages?.lastOrNull() - - // Only an image page needs the lookup - nothing on it names its ReaderPage. Evicted - // means scrolled well clear of, so there is no progress left to report. - return (viewerPageFor(imagePage) as? ViewerReaderPage)?.page - } - - override fun destroy() { - state.onViewport = null - lastReadThrough = null - wasAtEdge = null - super.destroy() - } - - private fun scrollByHalfPage(direction: Int) { - state.animateScroll(direction * state.height / 2f) - } - - override fun moveRight() = scrollByHalfPage(1) - - override fun moveLeft() = scrollByHalfPage(-1) - - override fun moveToPage(page: ReaderPage) { - super.moveToPage(page) - wasAtEdge = null - lastReadThrough = null - // Also for a jump to the page already showing, which turns nothing to slide in. - state.resetScroll() - } - - override fun animateTurn(direction: Int, fromSpread: ImagePage) { - state.animateSlideIn(direction) - } -} diff --git a/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt new file mode 100644 index 000000000..f78932a68 --- /dev/null +++ b/core/common/src/main/kotlin/eu/kanade/tachiyomi/util/system/GLUtil.kt @@ -0,0 +1,60 @@ +package eu.kanade.tachiyomi.util.system + +import javax.microedition.khronos.egl.EGL10 +import javax.microedition.khronos.egl.EGLConfig +import javax.microedition.khronos.egl.EGLContext +import kotlin.math.max + +object GLUtil { + val DEVICE_TEXTURE_LIMIT: Int by lazy { + // Get EGL Display + val egl = EGLContext.getEGL() as EGL10 + val display = egl.eglGetDisplay(EGL10.EGL_DEFAULT_DISPLAY) + + // Initialise + val version = IntArray(2) + egl.eglInitialize(display, version) + + // Query total number of configurations + val totalConfigurations = IntArray(1) + egl.eglGetConfigs(display, null, 0, totalConfigurations) + + // Query actual list configurations + val configurationsList = arrayOfNulls(totalConfigurations[0]) + egl.eglGetConfigs(display, configurationsList, totalConfigurations[0], totalConfigurations) + + val textureSize = IntArray(1) + var maximumTextureSize = 0 + + // Iterate through all the configurations to located the maximum texture size + for (i in 0.. by lazy { + val steps = DEVICE_TEXTURE_LIMIT / MULTIPLIER + buildList(steps) { + add(DEVICE_TEXTURE_LIMIT) + for (step in steps downTo 2) { + val value = step * MULTIPLIER + if (value >= DEVICE_TEXTURE_LIMIT) continue + add(value) + } + } + } +} + +private const val MULTIPLIER: Int = 1024 diff --git a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt index b40468474..304a6bf75 100644 --- a/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt +++ b/core/common/src/main/kotlin/tachiyomi/core/common/util/system/ImageUtil.kt @@ -21,11 +21,13 @@ import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.get import androidx.core.graphics.green import androidx.core.graphics.red -import ca.mpreg.imagedecoder.ImageDecoder import com.hippo.unifile.UniFile +import eu.kanade.tachiyomi.util.system.GLUtil import logcat.LogPriority import okio.Buffer import okio.BufferedSource +import tachiyomi.decoder.Format +import tachiyomi.decoder.ImageDecoder import java.io.InputStream import java.util.Locale import kotlin.math.abs @@ -47,20 +49,17 @@ object ImageUtil { fun findImageType(stream: InputStream): ImageType? { return try { - val format = ImageDecoder.open(stream).use { dec -> dec.format } - when (format) { - ImageDecoder.Format.JPEG -> ImageType.JPEG - ImageDecoder.Format.PNG -> ImageType.PNG - ImageDecoder.Format.WEBP -> ImageType.WEBP - ImageDecoder.Format.GIF -> ImageType.GIF - ImageDecoder.Format.AVIF -> ImageType.AVIF - ImageDecoder.Format.HEIF -> ImageType.HEIF - ImageDecoder.Format.JXL -> ImageType.JXL - ImageDecoder.Format.JP2 -> ImageType.JP2 + when (getImageType(stream)?.format) { + Format.Avif -> ImageType.AVIF + Format.Gif -> ImageType.GIF + Format.Heif -> ImageType.HEIF + Format.Jpeg -> ImageType.JPEG + Format.Jxl -> ImageType.JXL + Format.Png -> ImageType.PNG + Format.Webp -> ImageType.WEBP else -> null } } catch (e: Exception) { - logcat(LogPriority.ERROR) { "findImageType: ${e.message}" } null } } @@ -72,22 +71,38 @@ object ImageUtil { fun isAnimatedAndSupported(source: BufferedSource): Boolean { return try { - val type = findImageType(source.peek().inputStream()) ?: return false - when (type) { - ImageType.GIF -> true - ImageType.WEBP, ImageType.HEIF -> { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return false - ImageDecoder.open(source.peek().inputStream()).use { dec -> dec.pages > 1 } - } - + val type = getImageType(source.peek().inputStream()) ?: return false + // https://coil-kt.github.io/coil/getting_started/#supported-image-formats + when (type.format) { + Format.Gif -> true + // Animated WebP on Android 9+ + Format.Webp -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + // Animated Heif on Android 11+ + Format.Heif -> type.isAnimated && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R else -> false } } catch (e: Exception) { - logcat(LogPriority.ERROR) { "isAnimatedAndSupported: ${e.message}" } false } } + private fun getImageType(stream: InputStream): tachiyomi.decoder.ImageType? { + val bytes = ByteArray(32) + + val length = if (stream.markSupported()) { + stream.mark(bytes.size) + stream.read(bytes, 0, bytes.size).also { stream.reset() } + } else { + stream.read(bytes, 0, bytes.size) + } + + if (length == -1) { + return null + } + + return ImageDecoder.findType(bytes) + } + enum class ImageType(val mime: String, val extension: String) { AVIF("image/avif", "avif"), GIF("image/gif", "gif"), @@ -96,8 +111,6 @@ object ImageUtil { JXL("image/jxl", "jxl"), PNG("image/png", "png"), WEBP("image/webp", "webp"), - JP2("image/jp2", "jp2"), - JPX("image/jpx", "jpx"), } /** @@ -299,24 +312,30 @@ object ImageUtil { val bottomOffset = topOffset + splitHeight } + fun canUseHardwareBitmap(bitmap: Bitmap): Boolean { + return canUseHardwareBitmap(bitmap.width, bitmap.height) + } + + fun canUseHardwareBitmap(imageSource: BufferedSource): Boolean { + return with(extractImageOptions(imageSource)) { + canUseHardwareBitmap(outWidth, outHeight) + } + } + + var hardwareBitmapThreshold: Int = GLUtil.SAFE_TEXTURE_LIMIT + + private fun canUseHardwareBitmap(width: Int, height: Int): Boolean { + if (HARDWARE_BITMAP_UNSUPPORTED) return false + return maxOf(width, height) <= hardwareBitmapThreshold + } + /** * Algorithm for determining what background to accompany a comic/manga page */ fun chooseBackground(context: Context, imageStream: InputStream): Drawable { - val image = try { - ImageDecoder.open(imageStream).use { it.decodeNext() to it.isHdr }.let { (res, hdr) -> - res.use { - val config = if (hdr) Bitmap.Config.RGBA_F16 else Bitmap.Config.ARGB_8888 - createBitmap(res.width, res.height, config).also { bitmap -> - res.image.rewind() - bitmap.copyPixelsFromBuffer(res.image) - } - } - } - } catch (e: Exception) { - logcat(LogPriority.ERROR) { "chooseBackground: ${e.message}" } - null - } + val decoder = ImageDecoder.newInstance(imageStream) + val image = decoder?.decode() + decoder?.recycle() if (image == null) return Color.WHITE.toDrawable() return try { @@ -564,6 +583,121 @@ object ImageUtil { } private val optimalImageHeight = getDisplayMaxHeightInPx * 2 + + /** + * Taken from Coil + * (https://github.com/coil-kt/coil/blob/1674d3516f061aeacbe749a435b1924f9648fd41/coil-core/src/androidMain/kotlin/coil3/util/hardwareBitmaps.kt) + * --- + * Maintains a list of devices with broken/incomplete/unstable hardware bitmap implementations. + * + * Model names are retrieved from + * [Google's official device list](https://support.google.com/googleplay/answer/1727131?hl=en). + * + */ + val HARDWARE_BITMAP_UNSUPPORTED = when (Build.VERSION.SDK_INT) { + 26 -> run { + val model = Build.MODEL ?: return@run false + + // Samsung Galaxy (ALL) + if (model.removePrefix("SAMSUNG-").startsWith("SM-")) return@run true + + val device = Build.DEVICE ?: return@run false + + return@run device in arrayOf( + "nora", "nora_8917", "nora_8917_n", // Moto E5 + "james", "rjames_f", "rjames_go", "pettyl", // Moto E5 Play + "hannah", "ahannah", "rhannah", // Moto E5 Plus + + "ali", "ali_n", // Moto G6 + "aljeter", "aljeter_n", "jeter", // Moto G6 Play + "evert", "evert_n", "evert_nt", // Moto G6 Plus + + "G3112", "G3116", "G3121", "G3123", "G3125", // Xperia XA1 + "G3412", "G3416", "G3421", "G3423", "G3426", // Xperia XA1 Plus + "G3212", "G3221", "G3223", "G3226", // Xperia XA1 Ultra + + "BV6800Pro", // BlackView BV6800Pro + "CatS41", // Cat S41 + "Hi9Pro", // CHUWI Hi9 Pro + "manning", // Lenovo K8 Note + "N5702L", // NUU Mobile G3 + ) + } + + 27 -> run { + val device = Build.DEVICE ?: return@run false + + return@run device in arrayOf( + "mcv1s", // LG Tribute Empire + "mcv3", // LG K11 + "mcv5a", // LG Q7 + "mcv7a", // LG Stylo 4 + + "A30ATMO", // T-Mobile REVVL 2 + "A70AXLTMO", // T-Mobile REVVL 2 PLUS + + "A3A_8_4G_TMO", // Alcatel 9027W + "Edison_CKT", // Alcatel ONYX + "EDISON_TF", // Alcatel TCL XL2 + "FERMI_TF", // Alcatel A501DL + "U50A_ATT", // Alcatel TETRA + "U50A_PLUS_ATT", // Alcatel 5059R + "U50A_PLUS_TF", // Alcatel TCL LX + "U50APLUSTMO", // Alcatel 5059Z + "U5A_PLUS_4G", // Alcatel 1X + + "RCT6513W87DK5e", // RCA Galileo Pro + "RCT6873W42BMF9A", // RCA Voyager + "RCT6A03W13", // RCA 10 Viking + "RCT6B03W12", // RCA Atlas 10 Pro + "RCT6B03W13", // RCA Atlas 10 Pro+ + "RCT6T06E13", // RCA Artemis 10 + + "A3_Pro", // Umidigi A3 Pro + "One", // Umidigi One + "One_Max", // Umidigi One Max + "One_Pro", // Umidigi One Pro + "Z2", // Umidigi Z2 + "Z2_PRO", // Umidigi Z2 Pro + + "Armor_3", // Ulefone Armor 3 + "Armor_6", // Ulefone Armor 6 + + "Blackview", // Blackview BV6000 + "BV9500", // Blackview BV9500 + "BV9500Pro", // Blackview BV9500Pro + + "A6L-C", // Nuu A6L-C + "N5002LA", // Nuu A7L + "N5501LA", // Nuu A5L + + "Power_2_Pro", // Leagoo Power 2 Pro + "Power_5", // Leagoo Power 5 + "Z9", // Leagoo Z9 + + "V0310WW", // Blu VIVO VI+ + "V0330WW", // Blu VIVO XI + + "A3", // BenQ A3 + "ASUS_X018_4", // Asus ZenFone Max Plus M1 (ZB570TL) + "C210AE", // Wiko Life + "fireball", // DROID Incredible 4G LTE + "ILA_X1", // iLA X1 + "Infinix-X605_sprout", // Infinix NOTE 5 Stylus + "j7maxlte", // Samsung Galaxy J7 Max + "KING_KONG_3", // Cubot King Kong 3 + "M10500", // Packard Bell M10500 + "S70", // Altice ALTICE S70 + "S80Lite", // Doogee S80Lite + "SGINO6", // SGiNO 6 + "st18c10bnn", // Barnes and Noble BNTV650 + "TECNO-CA8", // Tecno CAMON X Pro, + "SHIFT6m", // SHIFT 6m + ) + } + + else -> false + } } val getDisplayMaxHeightInPx: Int diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index caee21afb..88267b825 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ firebase-bom = "35.0.0" firebase-crashlytics = "3.0.8" flexibleAdapter = "5.1.0" google-services = "4.5.0" -image-decoder = "16" +image-decoder = "e03b81e18a" injekt = "1.16.1" jsoup = "1.23.2" junit = "6.1.3" @@ -70,13 +70,12 @@ spotless = "8.10.4" sqldelight = "2.4.1" sqldelight-androidx-driver = "0.2.2" stringSimilarity = "0.1.0" -subsamplingScaleImageView = "1b283e2bc4" +subsamplingScaleImageView = "66e0db195d" swipe = "1.3.0" tapmoc = "0.4.2" unifile = "08f224c8f9" valkyrie = "0.5.2" voyager = "2.2.21-1.10.3" -webgpuviewer = "52" xmlutil = "1.0.2" [libraries] @@ -140,7 +139,7 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } flexibleAdapter = { module = "eu.davidea:flexible-adapter", version.ref = "flexibleAdapter" } -image-decoder = { module = "ca.mpreg:imagedecoder", version.ref = "image-decoder" } +image-decoder = { module = "com.github.mihonapp:image-decoder", version.ref = "image-decoder" } injekt = { module = "uy.kohesive.injekt:injekt-core", version.ref = "injekt" } jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } @@ -190,14 +189,13 @@ sqldelight-async = { module = "app.cash.sqldelight:async-extensions", version.re sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqldelight-sqliteDialect338 = { module = "app.cash.sqldelight:sqlite-3-38-dialect", version.ref = "sqldelight" } stringSimilarity = { module = "com.aallam.similarity:string-similarity-kotlin", version.ref = "stringSimilarity" } -subsamplingScaleImageView = { module = "com.github.mihonapp:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" } +subsamplingScaleImageView = { module = "com.github.tachiyomiorg:subsampling-scale-image-view", version.ref = "subsamplingScaleImageView" } swipe = { module = "me.saket.swipe:swipe", version.ref = "swipe" } tapmoc-gradle = { module = "com.gradleup.tapmoc:tapmoc-gradle-plugin", version.ref = "tapmoc" } unifile = { module = "com.github.mihon:unifile", version.ref = "unifile" } voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" } voyager-tabNavigator = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" } voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" } -webgpuviewer = { module = "ca.mpreg:webgpuviewer", version.ref = "webgpuviewer" } xmlutil-core = { module = "io.github.pdvrieze.xmlutil:core-android", version.ref = "xmlutil" } xmlutil-serialization = { module = "io.github.pdvrieze.xmlutil:serialization", version.ref = "xmlutil" } diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 19925c456..dbbbca520 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -427,7 +427,9 @@ Place vertical navigator on the left side Show reading mode Briefly show current mode when reader is opened - Use high quality renderer + Custom hardware bitmap threshold + Default (%d) + If reader loads a blank image incrementally reduce the threshold.\nSelected: %s Custom display profile Crop borders Custom brightness @@ -517,34 +519,6 @@ Low Lowest Disable zoom out - WebGPU - Transition animation - Transition animation (dual) - Basic - Page flip (left) - Page flip (right) - Stack (left) - Stack (right) - Stack (up) - Stack (down) - Sphere - Cube (Inside) - Cube (Outside) - Fade - Fade to white - None - Page flip - Display cutout mode - Display cutout mode (dual) - Ignore - Avoid - Shift - Dual page view - Never - Always - When wide - Min width - Gap Delete chapters