Bump webgpuviewer (#4146)

Co-authored-by: AntsyLich <59261191+antsylich@users.noreply.github.com>
This commit is contained in:
w
2026-10-20 07:16:11 +00:00
committed by GitHub
co-authored by AntsyLich
parent 4493166985
commit 487c313d31
10 changed files with 925 additions and 459 deletions
@@ -8,7 +8,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
@@ -16,8 +15,8 @@ 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.webgpu.WebGpuViewerSettings
import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonViewer
import mihon.app.di.appGraph
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
@@ -29,10 +28,15 @@ import java.text.NumberFormat
@Composable
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()
if (viewer is WebGpuViewer) {
WebGpuViewerSettings(viewModel)
return
}
HeadingItem(MR.strings.pref_category_for_this_series)
val manga by viewModel.mangaFlow.collectAsState()
val readingMode = remember(manga) { ReadingMode.fromPreference(manga?.readingMode?.toInt()) }
SettingsChipRow(MR.strings.pref_category_reading_mode) {
ReadingMode.entries.map {
@@ -44,62 +48,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 +59,10 @@ internal fun ColumnScope.ReadingModePage(viewModel: ReaderSettingsViewModel) {
}
}
when (viewer) {
is WebtoonViewer -> WebtoonViewerSettings(viewModel)
is WebGpuViewer -> WebGpuViewerSettings(viewModel)
else -> PagerViewerSettings(viewModel)
if (viewer is WebtoonViewer) {
WebtoonViewerSettings(viewModel)
} else {
PagerViewerSettings(viewModel)
}
}
@@ -264,7 +212,7 @@ private fun ColumnScope.WebtoonViewerSettings(viewModel: ReaderSettingsViewModel
}
@Composable
private fun ColumnScope.TapZonesItems(
internal fun TapZonesItems(
selected: Int,
onSelect: (Int) -> Unit,
invertMode: ReaderPreferences.TappingInvertMode,
@@ -292,120 +240,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)) },
)
}
}
}
}
@@ -3,7 +3,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
@@ -64,96 +63,73 @@ 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 {
return 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,
// Raw frame for custom rendering; sampling is the caller's.
if (options.newDecoder) {
return@use DecodeResult(
image = DecodeResultImage(
dec.decodeNext(),
dec.isHdr,
dec.hdrHeadroom,
if (dec.hdrKind == ImageDecoder.HdrKind.GAINMAP) dec.getGainmap() else null,
),
isSampled = false,
)
}
// Shrunk while decoding. Sizes round up, so may be a pixel over BitmapFactory's.
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = dec.width,
srcHeight = dec.height,
dstWidth = options.size.widthPx(options.scale) { dec.width },
dstHeight = options.size.heightPx(options.scale) { dec.height },
scale = options.scale,
)
DecodeResult(
image = dec.decodeToBitmap(null, sampleSize).asImage(),
isSampled = sampleSize > 1,
)
}
}
val srcWidth = res.width
val srcHeight = res.height
// 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,
)
}
// 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,
dstWidth = dstWidth,
dstHeight = dstHeight,
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()
// 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
}
return DecodeResult(
image = bitmap.asImage(),
isSampled = sampleSize > 1,
)
}
/**
* 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.
* one Java bitmap on top of the frame. The frame is decoded once, at the largest whole fraction
* still at least the strip width: per-strip regions would re-decode the rows above each.
*
* 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 (frame, isHdr, widths) = resources.source().use {
ImageDecoder.open(it.inputStream()).use { dec ->
val bytesPerPixel = if (dec.isHdr) 8 else 4
val viewWidth = options.size.widthPx(options.scale) { dec.width }
val sampleSize = DecodeUtils.calculateInSampleSize(
srcWidth = dec.width,
srcHeight = dec.height,
dstWidth = viewWidth,
dstHeight = options.size.heightPx(options.scale) { dec.height },
scale = options.scale,
)
val budgetWidth = sqrt(MAX_PAGE_BYTES.toDouble() / bytesPerPixel * dec.width / dec.height).toInt()
val stripWidth = maxOf(viewWidth, min(dec.width / sampleSize, budgetWidth))
// Older GPUs can't draw a texture wider than this
.coerceAtMost(MAX_STRIP_WIDTH)
.coerceIn(1, dec.width)
// ceil(width / decodeSample) >= stripWidth: strips only scale down
val decodeSample = (dec.width / stripWidth).coerceAtLeast(1)
Triple(dec.decode(null, decodeSample), dec.isHdr, stripWidth to dec.width)
}
}
val (stripWidth, pageWidth) = widths
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
// Strips of about the same size in bytes, so each takes about as long to upload
@@ -179,7 +155,7 @@ class ImageDecoder(private val resources: ImageSource, private val options: Opti
strip.copy(config, false).also { if (strip !== band) strip.recycle() }
}
band.recycle()
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < frame.width)
DecodeResult(image = StripsImage(strips), isSampled = stripWidth < pageWidth)
}
}
@@ -6,6 +6,8 @@ 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.ui.reader.viewer.webgpu.WebGpuShader
import eu.kanade.tachiyomi.ui.reader.viewer.webgpu.WebGpuShaders
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
@@ -220,6 +222,25 @@ class ReaderPreferences(
val continuousGap: Preference<Int> = preferenceStore.getInt("webgpu_continuous_gap", 10)
val disableDoubleTapGestures: Preference<Boolean> =
preferenceStore.getBoolean("webgpu_disable_double_tap_gestures", false)
/** In run order. */
val imageFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
"webgpu_image_filters",
emptyList(),
WebGpuShaders::serialize,
WebGpuShaders::deserialize,
)
/** In run order. */
val tileFilters: Preference<List<WebGpuShader>> = preferenceStore.getObjectFromString(
"webgpu_tile_filters",
emptyList(),
WebGpuShaders::serialize,
WebGpuShaders::deserialize,
)
// endregion
enum class FlashColor {
@@ -0,0 +1,255 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.Button
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
import ca.mpreg.webgpuviewer.viewer.ComposePageSize
import ca.mpreg.webgpuviewer.viewer.ImagePage
import eu.kanade.presentation.reader.ChapterTransition
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.presentation.util.formattedMessage
import eu.kanade.tachiyomi.ui.reader.model.ChapterTransition
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CombinedCircularProgressIndicator
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.source.local.isLocal
/** The side padding `PagerTransitionHolder` gave the same transition view. */
private const val TRANSITION_SIDE_PADDING = 64
/** The padding `WebtoonTransitionHolder` gave it, which took only the height it needed. */
private const val STRIP_TRANSITION_SIDE_PADDING = 32
private const val STRIP_TRANSITION_VERTICAL_PADDING = 128
/** How much of the viewport `WebtoonPageHolder` gave its error layout. */
private const val STRIP_ERROR_HEIGHT_FRACTION = 0.8f
/** The top margin `WebtoonPageHolder` gave its spinner, as a fraction of the viewport. */
private const val STRIP_PROGRESS_TOP_FRACTION = 0.25f
/**
* The reader's own chapter and page chrome, as composables, in the reader's theme.
*
* Built on the reader background, which is fixed for the page's life: a theme change rebuilds every
* page (see [WebGpuViewer]'s image property listener).
*/
abstract class ReaderComposePage(
protected val viewer: WebGpuViewer,
size: ComposePageSize = ComposePageSize.Auto,
) : ImagePage.Compose(size, viewer.readerBackgroundColor()) {
/**
* The theme goes on here because the library's page knows nothing of the app's colours.
*
* Under the reader's own night mode rather than the app's: the page sits on the reader
* background, so a light app over a black reader would otherwise draw dark text on black.
* [TachiyomiTheme] reads that from the configuration, which the pager and webtoon adapters
* used to get by inflating their views from a reader-themed context.
*/
@Composable
final override fun BoxScope.Content() {
val context = viewer.readerThemedContext
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
) {
TachiyomiTheme { PageContent() }
}
}
@Composable
protected abstract fun PageContent()
}
/**
* A page that failed to load or decode, with the pager's error layout: the message, Retry, and
* Open in WebView for an image that came off the web.
*/
class ErrorPage internal constructor(
viewer: WebGpuViewer,
private val viewerPage: WebGpuViewer.ViewerReaderPage?,
private val error: Throwable?,
) : ReaderComposePage(
viewer,
// WebtoonPageHolder gave its error layout less than a screen, so the page below shows through.
if (viewer is WebGpuViewerContinuous) ComposePageSize.Wrap else ComposePageSize.Auto,
) {
@Composable
override fun PageContent() {
val context = LocalContext.current
// As the pager's own error layout: only offered for an image that came off the web.
val imageUrl = viewerPage?.page?.imageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }
val strip = if (viewer is WebGpuViewerContinuous) {
Modifier.fillMaxHeight(STRIP_ERROR_HEIGHT_FRACTION).wrapContentHeight()
} else {
Modifier
}
ReaderError(
modifier = strip,
message = with(context) { error?.formattedMessage },
onRetry = { viewerPage?.let { viewer.retryLoad(it) } },
onOpenInWebView = imageUrl?.let {
{
val sourceId = viewer.activity.viewModel.manga?.source
context.startActivity(WebViewActivity.newIntent(context, it, sourceId))
}
},
)
}
}
/** `reader_error.xml`, in Compose. */
@Composable
private fun ReaderError(
message: String?,
onRetry: () -> Unit,
onOpenInWebView: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = message ?: stringResource(MR.strings.decode_image_error),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
)
Button(onClick = onRetry) {
Text(stringResource(MR.strings.action_retry))
}
if (onOpenInWebView != null) {
Button(onClick = onOpenInWebView) {
Text(stringResource(MR.strings.action_open_in_web_view))
}
}
}
}
/** A page still downloading: the reader's progress ring. */
class ProgressPage(viewer: WebGpuViewer) : ReaderComposePage(viewer) {
/**
* 0 to 1. On the page, not in the composition, which is dropped when the page goes out of
* reach. Written from the loader's collector; snapshot state takes writes from any thread.
*/
var progress: Float
get() = progressState
set(value) {
progressState = value.fastCoerceIn(0f, 1f)
}
private var progressState by mutableFloatStateOf(0f)
@Composable
override fun PageContent() {
if (viewer !is WebGpuViewerContinuous) {
CombinedCircularProgressIndicator(progress = { progress })
return
}
// WebtoonPageHolder centred its spinner below a quarter-height top margin, which puts it
// at five eighths of the slot rather than halfway down.
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = maxHeight * STRIP_PROGRESS_TOP_FRACTION),
contentAlignment = Alignment.Center,
) {
CombinedCircularProgressIndicator(progress = { progress })
}
}
}
}
/** The page between two chapters, or before the first and after the last. */
class TransitionPage(
viewer: WebGpuViewer,
val prevChapter: ReaderChapter?,
val nextChapter: ReaderChapter?,
) : ReaderComposePage(viewer, ComposePageSize.Wrap) {
/** As the pager and webtoon adapters framed it: leaving [prevChapter] for [nextChapter]. */
private val transition: ChapterTransition? = when {
prevChapter != null -> ChapterTransition.Next(prevChapter, nextChapter)
nextChapter != null -> ChapterTransition.Prev(nextChapter, null)
else -> null
}
@Composable
override fun PageContent() {
val transition = transition ?: return
val goingToChapterDownloaded by produceState(false, transition) {
value = withContext(Dispatchers.IO) { isDownloaded(transition) }
}
CompositionLocalProvider(
LocalTextStyle provides MaterialTheme.typography.bodySmall,
LocalContentColor provides MaterialTheme.colorScheme.onBackground,
) {
// The padding the holder each viewer used to inflate this into gave it.
val padding = if (viewer is WebGpuViewerContinuous) {
Modifier.padding(
horizontal = STRIP_TRANSITION_SIDE_PADDING.dp,
vertical = STRIP_TRANSITION_VERTICAL_PADDING.dp,
)
} else {
Modifier.padding(horizontal = TRANSITION_SIDE_PADDING.dp)
}
Box(modifier = padding) {
ChapterTransition(
transition = transition,
currChapterDownloaded = transition.from.pageLoader?.isLocal == true,
goingToChapterDownloaded = goingToChapterDownloaded,
)
}
}
}
/** `ReaderTransitionView.bind`'s answer, which reads the disk. */
private suspend fun isDownloaded(transition: ChapterTransition): Boolean {
val manga = viewer.activity.viewModel.manga ?: return false
if (manga.isLocal()) return true
val chapter = transition.to?.chapter ?: return false
val source = viewer.activity.viewModel.source ?: return false
return viewer.downloadManager.isChapterDownloadedOnDisk(
chapterName = chapter.name,
chapterScanlator = chapter.scanlator,
chapterUrl = chapter.url,
mangaTitle = manga.title,
source = source,
)
}
}
@@ -73,6 +73,16 @@ class WebGpuConfig(
var continuousGap = 10
private set
var imageFilters = emptyList<WebGpuShader>()
private set
var imageFiltersChangedListener: (() -> Unit)? = null
var tileFilters = emptyList<WebGpuShader>()
private set
var tileFiltersChangedListener: (() -> Unit)? = null
init {
readerPreferences.readerTheme.register(
{
@@ -161,10 +171,24 @@ class WebGpuConfig(
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.disableDoubleTapGestures.register({
viewer.pager.state.doubleTapGesturesEnabled = !it
})
readerPreferences.continuousGap.register(
{ continuousGap = it },
{ imagePropertyChangedListener?.invoke() },
)
readerPreferences.imageFilters.register(
{ imageFilters = it },
{ imageFiltersChangedListener?.invoke() },
)
readerPreferences.tileFilters.register(
{ tileFilters = it },
{ tileFiltersChangedListener?.invoke() },
)
}
private fun zoomTypeFromPreference(value: Int) {
@@ -0,0 +1,113 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import ca.mpreg.webgpuviewer.renderer.ImageFilterWgsl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import tachiyomi.core.common.preference.Preference
import java.io.File
import java.util.UUID
/** A user's shader: [file] in [WebGpuShaders.dir], shown as [name]. */
data class WebGpuShader(val file: String, val name: String)
/** Picked shaders are copied in, so they outlive the picker's URI permission. */
object WebGpuShaders {
private const val MAX_BYTES = 1 shl 20
/** What [import] names files; anything else in a preference is not ours to read or delete. */
private val FILE = Regex("""[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.wgsl""")
/** Held while files and the preferences naming them change together. */
private val lock = Any()
private fun dir(context: Context) = File(context.filesDir, "webgpu_shaders")
fun serialize(shaders: List<WebGpuShader>): String =
shaders.joinToString("\n") { "${it.file}\t${it.name}" }
fun deserialize(text: String): List<WebGpuShader> = text.lineSequence()
.mapNotNull { line ->
val tab = line.indexOf('\t')
if (tab <= 0) return@mapNotNull null
val file = line.substring(0, tab)
if (FILE.matches(file)) WebGpuShader(file, line.substring(tab + 1)) else null
}
.distinctBy { it.file }
.toList()
/**
* Copies [uri] in and appends it to [pref]. Throws [IllegalArgumentException], with the reason, if
* it is not a filter this device runs.
*/
suspend fun import(context: Context, uri: Uri, pref: Preference<List<WebGpuShader>>) =
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
if (it.moveToFirst()) it.getString(0) else null
} ?: uri.lastPathSegment ?: "shader"
val source = resolver.openInputStream(uri)?.use { input ->
val buffer = ByteArray(MAX_BYTES + 1)
var size = 0
while (size < buffer.size) {
val read = input.read(buffer, size, buffer.size - size)
if (read < 0) break
size += read
}
require(size <= MAX_BYTES) { "$name is over ${MAX_BYTES shr 20} MB" }
buffer.decodeToString(0, size)
} ?: throw IllegalArgumentException("cannot open $name")
ImageFilterWgsl(source, name).validate()
val shader = WebGpuShader("${UUID.randomUUID()}.wgsl", name.replace(Regex("""[\t\r\n]"""), " "))
// Past validation, the copy and its entry land together or not at all.
withContext(NonCancellable) {
synchronized(lock) {
val file = File(dir(context).apply { mkdirs() }, shader.file)
try {
file.writeText(source)
pref.set(pref.get() + shader)
} catch (e: Exception) {
file.delete()
throw e
}
}
}
}
fun remove(context: Context, pref: Preference<List<WebGpuShader>>, shader: WebGpuShader) {
synchronized(lock) {
pref.set(pref.get().filter { it.file != shader.file })
File(dir(context), shader.file).delete()
}
}
/**
* Deletes copies neither of [prefs] names, and entries whose copy is gone - left by a restored
* backup, or a crash between the two.
*/
suspend fun prune(context: Context, prefs: List<Preference<List<WebGpuShader>>>) =
withContext(Dispatchers.IO) {
synchronized(lock) {
val dir = dir(context)
val present = dir.list()?.toSet().orEmpty()
val named = HashSet<String>()
for (pref in prefs) {
val shaders = pref.get()
val kept = shaders.filter { it.file in present }
if (kept.size != shaders.size) pref.set(kept)
kept.mapTo(named) { it.file }
}
for (file in present) {
if (file !in named) File(dir, file).delete()
}
}
}
/** Throws if the file is gone or no longer parses. */
fun load(context: Context, shader: WebGpuShader): ImageFilterWgsl =
ImageFilterWgsl(File(dir(context), shader.file).readText(), shader.name)
}
@@ -1,6 +1,6 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import android.graphics.Color
import android.content.Context
import android.graphics.PointF
import android.graphics.Rect
import android.os.SystemClock
@@ -8,17 +8,9 @@ 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
@@ -38,7 +30,6 @@ 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
@@ -98,18 +89,18 @@ open class WebGpuViewer(
@Volatile
private var cachedBackgroundColor: Int? = null
@Volatile
private var cachedOnBackgroundColor: Int? = null
protected fun readerBackgroundColor(): Int =
internal 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 }
@Volatile
private var cachedThemedContext: Context? = null
/** The reader's night mode rather than the app's - see [ReaderComposePage]. */
internal val readerThemedContext: Context
get() = cachedThemedContext ?: activity.createReaderThemeContext().also { cachedThemedContext = it }
val downloadManager by lazy { activity.appGraph.downloadManager }
protected val scope = MainScope()
@@ -248,8 +239,7 @@ open class WebGpuViewer(
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.imagePage = ErrorPage(this@WebGpuViewer, page, e)
page.preview = null
page.state = PageState.IDLE
cleanupImage(oldImagePage)
@@ -498,177 +488,6 @@ open class WebGpuViewer(
}
}
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<String> = 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?
@@ -688,7 +507,7 @@ open class WebGpuViewer(
override val prevChapter: ReaderChapter?,
override val nextChapter: ReaderChapter?,
) : ViewerPage() {
override var imagePage: ImagePage = TransitionPage(prevChapter, nextChapter)
override var imagePage: ImagePage = TransitionPage(this@WebGpuViewer, prevChapter, nextChapter)
override val prev: ViewerPage?
get() = prevChapter?.pages?.lastOrNull()?.let { getPage(it, currentPage) }
@@ -707,7 +526,7 @@ open class WebGpuViewer(
/** The decoded image's shape, or null while this page is still a placeholder. */
internal val aspectRatio: Float?
get() = (imagePage as? ImagePage.ImageSingle)?.let {
get() = (imagePage as? ImagePage.ImageSingle)?.takeIf { it !is ImagePage.Compose }?.let {
val height = it.trimHeight
if (it.isDecoded && height > 0) it.trimWidth.toFloat() / height else null
}
@@ -730,7 +549,7 @@ open class WebGpuViewer(
internal val standsAlone: Boolean
get() = taggedSpreadPosition == SpreadPosition.SINGLE || (aspectRatio ?: 0f) > wideAspect
override var imagePage: ImagePage = ProgressPage()
override var imagePage: ImagePage = ProgressPage(this@WebGpuViewer)
/** So a download streams once; weak, as it holds the whole download. Under [lock]. */
internal var streamedFrom: WeakReference<DownloadStream>? = null
@@ -738,12 +557,14 @@ open class WebGpuViewer(
/** A streaming decode owns the page. Under [lock]. */
internal var streaming = false
/** Not counted by [isDecoded]. */
/** Not counted by [isDecoded], and nor is the reader's chrome, which is always "decoded". */
@Volatile
internal var preview: ImagePage? = null
override val isDecoded
get() = imagePage.let { it !== preview && (it as? ImagePage.ImageSingle)?.isDecoded == true }
get() = imagePage.let {
it !== preview && it !is ImagePage.Compose && (it as? ImagePage.ImageSingle)?.isDecoded == true
}
override val prevChapter: ReaderChapter?
get() = when (page.chapter) {
@@ -975,7 +796,7 @@ open class WebGpuViewer(
config.imagePropertyChangedListener = {
// A theme change comes through here.
cachedBackgroundColor = null
cachedOnBackgroundColor = null
cachedThemedContext = null
val isDual = isDualPageMode()
pager.state.apply {
@@ -1052,6 +873,34 @@ open class WebGpuViewer(
val showOnStart = config.navigationOverlayOnStart || config.forceNavigationOverlay
activity.binding.navigationOverlay.setNavigation(config.navigator, showOnStart)
}
config.imageFiltersChangedListener = {
val shaders = config.imageFilters
// Else an older, slower load could land last.
imageFiltersJob?.cancel()
imageFiltersJob = scope.launch { pager.state.imageFilters = loadFilters(shaders) }
}
config.tileFiltersChangedListener = {
val shaders = config.tileFilters
tileFiltersJob?.cancel()
tileFiltersJob = scope.launch { pager.state.tileFilters = loadFilters(shaders) }
}
}
private var imageFiltersJob: Job? = null
private var tileFiltersJob: Job? = null
/** Fresh instances: one belongs to one viewer and one chain. Skips any that fail to load. */
private suspend fun loadFilters(shaders: List<WebGpuShader>) = withContext(Dispatchers.IO) {
shaders.mapNotNull { shader ->
try {
WebGpuShaders.load(activity, shader)
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "filter ${shader.name}" }
null
}
}
}
override fun destroy() {
@@ -1106,6 +955,22 @@ open class WebGpuViewer(
}
}
/** [ErrorPage]'s Retry: back to the ring, then loaded and decoded afresh. */
internal fun retryLoad(page: ViewerReaderPage) {
synchronized(lock) {
if (!pageInCache(page) || page.imagePage !is ErrorPage) return
val old = page.imagePage
page.imagePage = ProgressPage(this)
cleanupImage(old)
if (page.state != PageState.LOADING) page.state = PageState.IDLE
}
page.page.chapter.pageLoader?.retryPage(page.page)
synchronized(lock) {
if (pageInCache(page)) queueForDecode(page, prioritize = true)
}
pager.state.invalidate()
}
/** Downloads [page] if needed, then re-queues it for decode once ready. */
private fun startPageLoad(page: ViewerReaderPage) {
val loader = page.page.chapter.pageLoader ?: run {
@@ -1169,6 +1034,15 @@ open class WebGpuViewer(
downloadProgressJob.cancel()
synchronized(lock) {
val status = page.page.status
if (pageInCache(page) && status is Page.State.Error && !page.streaming) {
val old = page.imagePage
if (old is ProgressPage) {
page.imagePage = ErrorPage(this@WebGpuViewer, page, status.error)
cleanupImage(old)
pager.state.invalidate()
}
}
if (pageInCache(page) && page.state == PageState.LOADING) {
page.state = PageState.IDLE
if (page.page.status == Page.State.Ready && !page.isDecoded && !page.streaming) {
@@ -1299,11 +1173,12 @@ open class WebGpuViewer(
} finally {
synchronized(lock) {
page.streaming = false
// Failed download: back to the ring for a retry.
// Failed download: the error, with Retry.
val preview = page.preview
if (pageInCache(page) && page.imagePage === preview && page.page.status is Page.State.Error) {
val status = page.page.status
if (pageInCache(page) && page.imagePage === preview && status is Page.State.Error) {
page.preview = null
page.imagePage = ProgressPage()
page.imagePage = ErrorPage(this@WebGpuViewer, page, status.error)
cleanupImage(preview)
pager.state.invalidate()
}
@@ -0,0 +1,366 @@
package eu.kanade.tachiyomi.ui.reader.viewer.webgpu
import android.content.ActivityNotFoundException
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.manga.model.readerOrientation
import eu.kanade.domain.manga.model.readingMode
import eu.kanade.presentation.reader.settings.TapZonesItems
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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import logcat.LogPriority
import mihon.app.di.appGraph
import mihon.icons.materialsymbols.MaterialSymbols
import mihon.icons.materialsymbols.rounded.Add
import mihon.icons.materialsymbols.rounded.Delete
import mihon.icons.materialsymbols.rounded.DragHandle
import sh.calvin.reorderable.ReorderableColumn
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.CheckboxItem
import tachiyomi.presentation.core.components.HeadingItem
import tachiyomi.presentation.core.components.SettingsChipRow
import tachiyomi.presentation.core.components.SettingsItemsPaddings
import tachiyomi.presentation.core.components.SliderItem
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import java.text.NumberFormat
@Composable
internal fun WebGpuViewerSettings(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) {
ReadingMode.entries.map {
FilterChip(
selected = it == readingMode,
onClick = { viewModel.onChangeReadingMode(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
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.forEach {
FilterChip(
selected = it == orientation,
onClick = { viewModel.onChangeOrientation(it) },
label = { Text(stringResource(it.stringRes)) },
)
}
}
HeadingItem(MR.strings.webgpu_viewer)
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,
)
CheckboxItem(
label = stringResource(MR.strings.pref_disable_double_tap_gestures),
pref = viewModel.preferences.disableDoubleTapGestures,
)
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)) },
)
}
}
}
val context = LocalContext.current
LaunchedEffect(Unit) {
WebGpuShaders.prune(context, listOf(viewModel.preferences.imageFilters, viewModel.preferences.tileFilters))
}
ShaderList(MR.strings.pref_image_filters, viewModel.preferences.imageFilters)
ShaderList(MR.strings.pref_tile_filters, viewModel.preferences.tileFilters)
}
@Composable
private fun ShaderList(labelRes: StringResource, pref: Preference<List<WebGpuShader>>) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val filters by pref.collectAsState()
var error by remember { mutableStateOf<String?>(null) }
val pickShader = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
scope.launch {
try {
WebGpuShaders.import(context, uri, pref)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.WARN, e)
error = e.message ?: e.toString()
}
}
}
error?.let { message ->
AlertDialog(
onDismissRequest = { error = null },
confirmButton = {
TextButton(onClick = { error = null }) {
Text(stringResource(MR.strings.action_ok))
}
},
title = { Text(stringResource(MR.strings.image_filter_invalid)) },
text = {
SelectionContainer(Modifier.verticalScroll(rememberScrollState())) {
Text(
text = message,
fontFamily = FontFamily.Monospace,
style = MaterialTheme.typography.bodySmall,
)
}
},
)
}
HeadingItem(labelRes)
ReorderableColumn(
list = filters,
onSettle = { from, to ->
// The indices are into the list as drawn, which the preference may have moved past.
val moved = filters.toMutableList().apply { add(to, removeAt(from)) }
val current = pref.get()
val files = current.mapTo(HashSet()) { it.file }
val kept = moved.filter { it.file in files }
val keptFiles = kept.mapTo(HashSet()) { it.file }
pref.set(kept + current.filter { it.file !in keptFiles })
},
) { _, shader, _ ->
key(shader.file) {
ReorderableItem {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = SettingsItemsPaddings.Horizontal, end = MaterialTheme.padding.small),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = MaterialSymbols.Rounded.DragHandle,
contentDescription = null,
modifier = Modifier.draggableHandle(),
)
Text(
text = shader.name,
modifier = Modifier
.weight(1f)
.padding(horizontal = MaterialTheme.padding.medium),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(
onClick = { WebGpuShaders.remove(context, pref, shader) },
) {
Icon(
imageVector = MaterialSymbols.Rounded.Delete,
contentDescription = stringResource(MR.strings.action_delete),
)
}
}
}
}
}
TextButton(
onClick = {
try {
pickShader.launch(arrayOf("*/*"))
} catch (e: ActivityNotFoundException) {
error = e.message ?: e.toString()
}
},
modifier = Modifier.padding(horizontal = SettingsItemsPaddings.Horizontal - MaterialTheme.padding.small),
) {
Icon(imageVector = MaterialSymbols.Rounded.Add, contentDescription = null)
Text(
text = stringResource(MR.strings.action_add_shader),
modifier = Modifier.padding(start = MaterialTheme.padding.small),
)
}
}
+3 -3
View File
@@ -42,7 +42,7 @@ firebase-bom = "35.0.2"
firebase-crashlytics = "3.0.8"
flexibleAdapter = "5.1.0"
google-services = "4.5.0"
image-decoder = "16"
image-decoder = "17"
injekt = "1.16.1"
jsoup = "1.23.2"
junit = "6.1.3"
@@ -71,13 +71,13 @@ spotless = "8.10.4"
sqldelight = "2.4.1"
sqldelight-androidx-driver = "0.2.2"
stringSimilarity = "0.1.0"
subsamplingScaleImageView = "1b283e2bc4"
subsamplingScaleImageView = "72a0537151"
swipe = "1.3.0"
tapmoc = "0.5.0"
unifile = "08f224c8f9"
valkyrie = "0.5.2"
voyager = "2.2.21-1.10.3"
webgpuviewer = "52"
webgpuviewer = "53"
xmlutil = "1.0.2"
[libraries]
@@ -552,6 +552,11 @@
<string name="dual_page_view_wide">When wide</string>
<string name="pref_continuous_minwidth">Min width</string>
<string name="pref_continuous_gap">Gap</string>
<string name="pref_image_filters">Image filters</string>
<string name="pref_tile_filters">Tile filters</string>
<string name="pref_disable_double_tap_gestures">Disable double tap gestures</string>
<string name="action_add_shader">Add shader</string>
<string name="image_filter_invalid">Couldn\'t add shader</string>
<!-- Downloads section -->
<string name="pref_category_delete_chapters">Delete chapters</string>