mirror of
https://github.com/mihonapp/mihon.git
synced 2026-10-23 17:09:49 -07:00
Bump webgpuviewer (#4146)
Co-authored-by: AntsyLich <59261191+antsylich@users.noreply.github.com>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
+366
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user