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