mirror of
https://github.com/mihonapp/mihon.git
synced 2025-08-26 16:11:31 +02:00
Compare commits
26 Commits
abe627c823
...
8718d0c0a7
Author | SHA1 | Date | |
---|---|---|---|
|
8718d0c0a7 | ||
|
eac6118487 | ||
|
31e3d94a68 | ||
|
8302eb4a09 | ||
|
2ad98520aa | ||
|
f1ce205d00 | ||
|
5289830a84 | ||
|
dd932e1362 | ||
|
e85ce6456a | ||
|
9a3ffe2ea6 | ||
|
213effa169 | ||
|
25570147a1 | ||
|
e82a2f5f9f | ||
|
935c0c7e2e | ||
|
2ad462b4d8 | ||
|
7fd8f65352 | ||
|
b152e3881b | ||
|
f27ca3b1b2 | ||
|
843daa5304 | ||
|
f080a4937e | ||
|
8bba926891 | ||
|
5378c4c5d6 | ||
|
c94d212ef4 | ||
|
4c43a0ef66 | ||
|
ea0fe2414e | ||
|
015620711d |
10
.github/renovate.json5
vendored
10
.github/renovate.json5
vendored
@@ -6,12 +6,20 @@
|
||||
"schedule": ["every friday"],
|
||||
"labels": ["Dependencies"],
|
||||
"packageRules": [
|
||||
{
|
||||
"groupName": "Compose BOM",
|
||||
"matchPackageNames": [
|
||||
"dev.chrisbanes.compose:compose-bom"
|
||||
],
|
||||
"ignoreUnstable": false
|
||||
},
|
||||
{
|
||||
// Compiler plugins are tightly coupled to Kotlin version
|
||||
"groupName": "Kotlin",
|
||||
"matchPackagePrefixes": [
|
||||
"androidx.compose.compiler",
|
||||
"org.jetbrains.kotlin",
|
||||
"org.jetbrains.kotlin.",
|
||||
"org.jetbrains.kotlin:"
|
||||
],
|
||||
}
|
||||
]
|
||||
|
6
.github/workflows/build_pull_request.yml
vendored
6
.github/workflows/build_pull_request.yml
vendored
@@ -20,10 +20,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 # v2.1.2
|
||||
uses: gradle/wrapper-validation-action@216d1ad2b3710bf005dc39237337b9673fd8fcd5 # v3.3.2
|
||||
|
||||
- name: Dependency Review
|
||||
uses: actions/dependency-review-action@5bbc3ba658137598168acb2ab73b21c432dd411b # v4.2.5
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
distribution: adopt
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@e24011a3b5db78bd5ab798036042d9312002f252 # v3.2.0
|
||||
uses: gradle/actions/setup-gradle@db19848a5fa7950289d3668fb053140cf3028d43 # v3.3.2
|
||||
|
||||
- name: Build app and run unit tests
|
||||
run: ./gradlew detekt assembleStandardRelease testReleaseUnitTest
|
||||
|
6
.github/workflows/build_push.yml
vendored
6
.github/workflows/build_push.yml
vendored
@@ -17,10 +17,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone repo
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/wrapper-validation-action@b231772637bb498f11fdbc86052b6e8a8dc9fc92 # v2.1.2
|
||||
uses: gradle/wrapper-validation-action@216d1ad2b3710bf005dc39237337b9673fd8fcd5 # v3.3.2
|
||||
|
||||
- name: Setup Android SDK
|
||||
run: |
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
distribution: adopt
|
||||
|
||||
- name: Set up gradle
|
||||
uses: gradle/actions/setup-gradle@e24011a3b5db78bd5ab798036042d9312002f252 # v3.2.0
|
||||
uses: gradle/actions/setup-gradle@db19848a5fa7950289d3668fb053140cf3028d43 # v3.3.2
|
||||
|
||||
- name: Build app and run unit tests
|
||||
run: ./gradlew detekt assembleStandardRelease testReleaseUnitTest
|
||||
|
6
.github/workflows/issue_moderator.yml
vendored
6
.github/workflows/issue_moderator.yml
vendored
@@ -30,6 +30,12 @@ jobs:
|
||||
"ignoreCase": true,
|
||||
"labels": ["Cloudflare protected"],
|
||||
"message": "Refer to the **Solving Cloudflare issues** section at https://mihon.app/docs/guides/troubleshooting/#cloudflare. If it doesn't work, migrate to other sources or wait until they lower their protection."
|
||||
},
|
||||
{
|
||||
"type": "both",
|
||||
"regex": "^.*(myanimelist|mal).*$",
|
||||
"ignoreCase": true,
|
||||
"message": "For issues with linking MyAnimeList, please follow these steps:\n1. Update Mihon to version 0.16.4 or newer\n2. Change your default User-Agent (`More → Settings → Advanced → Default user agent string`)\n3. Close and restart Mihon\n4. Attempt to link MyAnimeList again\n\nIf you had MyAnimeList linked before, try to unlink it first before trying these steps."
|
||||
}
|
||||
]
|
||||
auto-close-ignore-label: do-not-autoclose
|
||||
|
@@ -286,7 +286,6 @@ tasks {
|
||||
"-opt-in=androidx.compose.animation.ExperimentalAnimationApi",
|
||||
"-opt-in=androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi",
|
||||
"-opt-in=coil3.annotation.ExperimentalCoilApi",
|
||||
"-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"-opt-in=kotlinx.coroutines.FlowPreview",
|
||||
"-opt-in=kotlinx.coroutines.InternalCoroutinesApi",
|
||||
|
@@ -5,7 +5,6 @@ import android.net.Uri
|
||||
import android.provider.Settings
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
@@ -354,10 +353,8 @@ private fun InfoText(
|
||||
primaryTextStyle: TextStyle = MaterialTheme.typography.bodyLarge,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val clickableModifier = if (onClick != null) {
|
||||
Modifier.clickable(interactionSource, indication = null) { onClick() }
|
||||
Modifier.clickable(interactionSource = null, indication = null, onClick = onClick)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
@@ -9,13 +9,13 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.ripple
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProgressIndicatorDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
@@ -8,7 +8,6 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -30,11 +29,11 @@ import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.DoneAll
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.RemoveDone
|
||||
import androidx.compose.material.ripple
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -191,7 +190,7 @@ private fun RowScope.Button(
|
||||
.size(48.dp)
|
||||
.weight(animatedWeight)
|
||||
.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
interactionSource = null,
|
||||
indication = ripple(bounded = false),
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
|
@@ -35,7 +35,6 @@ import androidx.core.net.toUri
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.hippo.unifile.UniFile
|
||||
import eu.kanade.core.util.isInkBookEInkDevice
|
||||
import eu.kanade.presentation.more.settings.Preference
|
||||
import eu.kanade.presentation.more.settings.screen.data.CreateBackupScreen
|
||||
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
|
||||
@@ -115,8 +114,12 @@ object SettingsDataScreen : SearchableSettings {
|
||||
// For some reason InkBook devices do not implement the SAF properly. Persistable URI grants do not
|
||||
// work. However, simply retrieving the URI and using it works fine for these devices. Access is not
|
||||
// revoked after the app is closed or the device is restarted.
|
||||
if (!isInkBookEInkDevice()) {
|
||||
// This also holds for some Samsung devices. Thus, we simply execute inside of a try-catch block and
|
||||
// ignore the exception if it is thrown.
|
||||
try {
|
||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||
} catch (e: RuntimeException) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
|
||||
UniFile.fromUri(context, uri)?.let {
|
||||
|
@@ -27,6 +27,7 @@ import eu.kanade.domain.ui.UiPreferences
|
||||
import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode
|
||||
import eu.kanade.tachiyomi.crash.CrashActivity
|
||||
import eu.kanade.tachiyomi.crash.GlobalExceptionHandler
|
||||
import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher
|
||||
import eu.kanade.tachiyomi.data.coil.MangaCoverFetcher
|
||||
import eu.kanade.tachiyomi.data.coil.MangaCoverKeyer
|
||||
import eu.kanade.tachiyomi.data.coil.MangaKeyer
|
||||
@@ -162,6 +163,7 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor
|
||||
add(MangaCoverFetcher.MangaCoverFactory(callFactoryLazy))
|
||||
add(MangaKeyer())
|
||||
add(MangaCoverKeyer())
|
||||
add(BufferedSourceFetcher.Factory())
|
||||
}
|
||||
crossfade((300 * this@App.animatorDurationScale).toInt())
|
||||
allowRgb565(DeviceUtil.isLowRamDevice(this@App))
|
||||
|
@@ -0,0 +1,38 @@
|
||||
package eu.kanade.tachiyomi.data.coil
|
||||
|
||||
import coil3.ImageLoader
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.request.Options
|
||||
import okio.BufferedSource
|
||||
|
||||
class BufferedSourceFetcher(
|
||||
private val data: BufferedSource,
|
||||
private val options: Options,
|
||||
) : Fetcher {
|
||||
|
||||
override suspend fun fetch(): FetchResult {
|
||||
return SourceFetchResult(
|
||||
source = ImageSource(
|
||||
source = data,
|
||||
fileSystem = options.fileSystem,
|
||||
),
|
||||
mimeType = null,
|
||||
dataSource = DataSource.MEMORY,
|
||||
)
|
||||
}
|
||||
|
||||
class Factory : Fetcher.Factory<BufferedSource> {
|
||||
|
||||
override fun create(
|
||||
data: BufferedSource,
|
||||
options: Options,
|
||||
imageLoader: ImageLoader,
|
||||
): Fetcher {
|
||||
return BufferedSourceFetcher(data, options)
|
||||
}
|
||||
}
|
||||
}
|
@@ -33,8 +33,7 @@ import eu.kanade.tachiyomi.ui.reader.viewer.webtoon.WebtoonSubsamplingImageView
|
||||
import eu.kanade.tachiyomi.util.system.GLUtil
|
||||
import eu.kanade.tachiyomi.util.system.animatorDurationScale
|
||||
import eu.kanade.tachiyomi.util.view.isVisibleOnScreen
|
||||
import java.io.InputStream
|
||||
import java.nio.ByteBuffer
|
||||
import okio.BufferedSource
|
||||
|
||||
/**
|
||||
* A wrapper view for showing page image.
|
||||
@@ -146,14 +145,14 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setImage(inputStream: InputStream, isAnimated: Boolean, config: Config) {
|
||||
fun setImage(source: BufferedSource, isAnimated: Boolean, config: Config) {
|
||||
this.config = config
|
||||
if (isAnimated) {
|
||||
prepareAnimatedImageView()
|
||||
setAnimatedImage(inputStream, config)
|
||||
setAnimatedImage(source, config)
|
||||
} else {
|
||||
prepareNonAnimatedImageView()
|
||||
setNonAnimatedImage(inputStream, config)
|
||||
setNonAnimatedImage(source, config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +261,7 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun setNonAnimatedImage(
|
||||
image: Any,
|
||||
data: Any,
|
||||
config: Config,
|
||||
) = (pageView as? SubsamplingScaleImageView)?.apply {
|
||||
setDoubleTapZoomDuration(config.zoomDuration.getSystemScaledDuration())
|
||||
@@ -283,10 +282,10 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
},
|
||||
)
|
||||
|
||||
when (image) {
|
||||
is BitmapDrawable -> setImage(ImageSource.bitmap(image.bitmap))
|
||||
is InputStream -> setImage(ImageSource.inputStream(image))
|
||||
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}")
|
||||
when (data) {
|
||||
is BitmapDrawable -> setImage(ImageSource.bitmap(data.bitmap))
|
||||
is BufferedSource -> setImage(ImageSource.inputStream(data.inputStream()))
|
||||
else -> throw IllegalArgumentException("Not implemented for class ${data::class.simpleName}")
|
||||
}
|
||||
isVisible = true
|
||||
}
|
||||
@@ -331,18 +330,13 @@ open class ReaderPageImageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
private fun setAnimatedImage(
|
||||
image: Any,
|
||||
data: Any,
|
||||
config: Config,
|
||||
) = (pageView as? AppCompatImageView)?.apply {
|
||||
if (this is PhotoView) {
|
||||
setZoomTransitionDuration(config.zoomDuration.getSystemScaledDuration())
|
||||
}
|
||||
|
||||
val data = when (image) {
|
||||
is Drawable -> image
|
||||
is InputStream -> ByteBuffer.wrap(image.readBytes())
|
||||
else -> throw IllegalArgumentException("Not implemented for class ${image::class.simpleName}")
|
||||
}
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(data)
|
||||
.memoryCachePolicy(CachePolicy.DISABLED)
|
||||
|
@@ -18,14 +18,13 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* View of the ViewPager that contains a page of a chapter.
|
||||
@@ -139,38 +138,30 @@ class PagerPageHolder(
|
||||
val streamFn = page.stream ?: return
|
||||
|
||||
try {
|
||||
val (bais, isAnimated, background) = withIOContext {
|
||||
streamFn().buffered(16).use { stream ->
|
||||
process(item, stream).use { itemStream ->
|
||||
val bais = ByteArrayInputStream(itemStream.readBytes())
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(bais)
|
||||
bais.reset()
|
||||
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
||||
ImageUtil.chooseBackground(context, bais)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
bais.reset()
|
||||
Triple(bais, isAnimated, background)
|
||||
}
|
||||
val (source, isAnimated, background) = withIOContext {
|
||||
val source = streamFn().use { process(item, Buffer().readFrom(it)) }
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||
val background = if (!isAnimated && viewer.config.automaticBackground) {
|
||||
ImageUtil.chooseBackground(context, source.peek().inputStream())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Triple(source, isAnimated, background)
|
||||
}
|
||||
withUIContext {
|
||||
bais.use {
|
||||
setImage(
|
||||
it,
|
||||
isAnimated,
|
||||
Config(
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
minimumScaleType = viewer.config.imageScaleType,
|
||||
cropBorders = viewer.config.imageCropBorders,
|
||||
zoomStartPosition = viewer.config.imageZoomType,
|
||||
landscapeZoom = viewer.config.landscapeZoom,
|
||||
),
|
||||
)
|
||||
if (!isAnimated) {
|
||||
pageBackground = background
|
||||
}
|
||||
setImage(
|
||||
source,
|
||||
isAnimated,
|
||||
Config(
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
minimumScaleType = viewer.config.imageScaleType,
|
||||
cropBorders = viewer.config.imageCropBorders,
|
||||
zoomStartPosition = viewer.config.imageZoomType,
|
||||
landscapeZoom = viewer.config.landscapeZoom,
|
||||
),
|
||||
)
|
||||
if (!isAnimated) {
|
||||
pageBackground = background
|
||||
}
|
||||
removeErrorLayout()
|
||||
}
|
||||
@@ -182,40 +173,40 @@ class PagerPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(page: ReaderPage, imageStream: BufferedInputStream): InputStream {
|
||||
private fun process(page: ReaderPage, imageSource: BufferedSource): BufferedSource {
|
||||
if (viewer.config.dualPageRotateToFit) {
|
||||
return rotateDualPage(imageStream)
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (!viewer.config.dualPageSplit) {
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
|
||||
if (page is InsertPage) {
|
||||
return splitInHalf(imageStream)
|
||||
return splitInHalf(imageSource)
|
||||
}
|
||||
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (!isDoublePage) {
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
|
||||
onPageSplit(page)
|
||||
|
||||
return splitInHalf(imageStream)
|
||||
return splitInHalf(imageSource)
|
||||
}
|
||||
|
||||
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageStream, rotation)
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageStream
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
private fun splitInHalf(imageStream: InputStream): InputStream {
|
||||
private fun splitInHalf(imageSource: BufferedSource): BufferedSource {
|
||||
var side = when {
|
||||
viewer is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.RIGHT
|
||||
viewer !is L2RPagerViewer && page is InsertPage -> ImageUtil.Side.LEFT
|
||||
@@ -231,7 +222,7 @@ class PagerPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
return ImageUtil.splitInHalf(imageStream, side)
|
||||
return ImageUtil.splitInHalf(imageSource, side)
|
||||
}
|
||||
|
||||
private fun onPageSplit(page: ReaderPage) {
|
||||
|
@@ -22,15 +22,14 @@ import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.core.common.util.lang.launchIO
|
||||
import tachiyomi.core.common.util.lang.withIOContext
|
||||
import tachiyomi.core.common.util.lang.withUIContext
|
||||
import tachiyomi.core.common.util.system.ImageUtil
|
||||
import tachiyomi.core.common.util.system.logcat
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
* Holder of the webtoon reader for a single page of a chapter.
|
||||
@@ -188,16 +187,14 @@ class WebtoonPageHolder(
|
||||
val streamFn = page?.stream ?: return
|
||||
|
||||
try {
|
||||
val (openStream, isAnimated) = withIOContext {
|
||||
val stream = streamFn().buffered(16)
|
||||
val openStream = process(stream)
|
||||
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(stream)
|
||||
Pair(openStream, isAnimated)
|
||||
val (source, isAnimated) = withIOContext {
|
||||
val source = streamFn().use { process(Buffer().readFrom(it)) }
|
||||
val isAnimated = ImageUtil.isAnimatedAndSupported(source)
|
||||
Pair(source, isAnimated)
|
||||
}
|
||||
withUIContext {
|
||||
frame.setImage(
|
||||
openStream,
|
||||
source,
|
||||
isAnimated,
|
||||
ReaderPageImageView.Config(
|
||||
zoomDuration = viewer.config.doubleTapAnimDuration,
|
||||
@@ -207,10 +204,6 @@ class WebtoonPageHolder(
|
||||
)
|
||||
removeErrorLayout()
|
||||
}
|
||||
// Suspend the coroutine to close the input stream only when the WebtoonPageHolder is recycled
|
||||
suspendCancellableCoroutine<Nothing> { continuation ->
|
||||
continuation.invokeOnCancellation { openStream.close() }
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
withUIContext {
|
||||
@@ -219,29 +212,29 @@ class WebtoonPageHolder(
|
||||
}
|
||||
}
|
||||
|
||||
private fun process(imageStream: BufferedInputStream): InputStream {
|
||||
private fun process(imageSource: BufferedSource): BufferedSource {
|
||||
if (viewer.config.dualPageRotateToFit) {
|
||||
return rotateDualPage(imageStream)
|
||||
return rotateDualPage(imageSource)
|
||||
}
|
||||
|
||||
if (viewer.config.dualPageSplit) {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
if (isDoublePage) {
|
||||
val upperSide = if (viewer.config.dualPageInvert) ImageUtil.Side.LEFT else ImageUtil.Side.RIGHT
|
||||
return ImageUtil.splitAndMerge(imageStream, upperSide)
|
||||
return ImageUtil.splitAndMerge(imageSource, upperSide)
|
||||
}
|
||||
}
|
||||
|
||||
return imageStream
|
||||
return imageSource
|
||||
}
|
||||
|
||||
private fun rotateDualPage(imageStream: BufferedInputStream): InputStream {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageStream)
|
||||
private fun rotateDualPage(imageSource: BufferedSource): BufferedSource {
|
||||
val isDoublePage = ImageUtil.isWideImage(imageSource)
|
||||
return if (isDoublePage) {
|
||||
val rotation = if (viewer.config.dualPageRotateToFitInvert) -90f else 90f
|
||||
ImageUtil.rotateImage(imageStream, rotation)
|
||||
ImageUtil.rotateImage(imageSource, rotation)
|
||||
} else {
|
||||
imageStream
|
||||
imageSource
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -24,11 +24,10 @@ import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import com.hippo.unifile.UniFile
|
||||
import logcat.LogPriority
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import tachiyomi.decoder.Format
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
import java.util.Locale
|
||||
@@ -76,9 +75,9 @@ object ImageUtil {
|
||||
?: "jpg"
|
||||
}
|
||||
|
||||
fun isAnimatedAndSupported(stream: InputStream): Boolean {
|
||||
fun isAnimatedAndSupported(source: BufferedSource): Boolean {
|
||||
return try {
|
||||
val type = getImageType(stream) ?: return false
|
||||
val type = getImageType(source.peek().inputStream()) ?: return false
|
||||
// https://coil-kt.github.io/coil/getting_started/#supported-image-formats
|
||||
when (type.format) {
|
||||
Format.Gif -> true
|
||||
@@ -125,18 +124,16 @@ object ImageUtil {
|
||||
*
|
||||
* @return true if the width is greater than the height
|
||||
*/
|
||||
fun isWideImage(imageStream: BufferedInputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream)
|
||||
fun isWideImage(imageSource: BufferedSource): Boolean {
|
||||
val options = extractImageOptions(imageSource)
|
||||
return options.outWidth > options.outHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the 'side' part from imageStream and return it as InputStream.
|
||||
* Extract the 'side' part from [BufferedSource] and return it as [BufferedSource].
|
||||
*/
|
||||
fun splitInHalf(imageStream: InputStream, side: Side): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
fun splitInHalf(imageSource: BufferedSource, side: Side): BufferedSource {
|
||||
val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
@@ -150,22 +147,20 @@ object ImageUtil {
|
||||
half.applyCanvas {
|
||||
drawBitmap(imageBitmap, part, singlePage, null)
|
||||
}
|
||||
val output = ByteArrayOutputStream()
|
||||
half.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
val output = Buffer()
|
||||
half.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
|
||||
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
return output
|
||||
}
|
||||
|
||||
fun rotateImage(imageStream: InputStream, degrees: Float): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
fun rotateImage(imageSource: BufferedSource, degrees: Float): BufferedSource {
|
||||
val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
|
||||
val rotated = rotateBitMap(imageBitmap, degrees)
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
val output = Buffer()
|
||||
rotated.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
|
||||
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
return output
|
||||
}
|
||||
|
||||
private fun rotateBitMap(bitmap: Bitmap, degrees: Float): Bitmap {
|
||||
@@ -176,10 +171,8 @@ object ImageUtil {
|
||||
/**
|
||||
* Split the image into left and right parts, then merge them into a new image.
|
||||
*/
|
||||
fun splitAndMerge(imageStream: InputStream, upperSide: Side): InputStream {
|
||||
val imageBytes = imageStream.readBytes()
|
||||
|
||||
val imageBitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
|
||||
fun splitAndMerge(imageSource: BufferedSource, upperSide: Side): BufferedSource {
|
||||
val imageBitmap = BitmapFactory.decodeStream(imageSource.inputStream())
|
||||
val height = imageBitmap.height
|
||||
val width = imageBitmap.width
|
||||
|
||||
@@ -201,9 +194,9 @@ object ImageUtil {
|
||||
drawBitmap(imageBitmap, leftPart, bottomPart, null)
|
||||
}
|
||||
|
||||
val output = ByteArrayOutputStream()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 100, output)
|
||||
return ByteArrayInputStream(output.toByteArray())
|
||||
val output = Buffer()
|
||||
result.compress(Bitmap.CompressFormat.JPEG, 100, output.outputStream())
|
||||
return output
|
||||
}
|
||||
|
||||
enum class Side {
|
||||
@@ -216,8 +209,8 @@ object ImageUtil {
|
||||
*
|
||||
* @return true if the height:width ratio is greater than 3.
|
||||
*/
|
||||
private fun isTallImage(imageStream: InputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
|
||||
private fun isTallImage(imageSource: BufferedSource): Boolean {
|
||||
val options = extractImageOptions(imageSource)
|
||||
return (options.outHeight / options.outWidth) > 3
|
||||
}
|
||||
|
||||
@@ -225,17 +218,18 @@ object ImageUtil {
|
||||
* Splits tall images to improve performance of reader
|
||||
*/
|
||||
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
|
||||
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
|
||||
val imageSource = imageFile.openInputStream().use { Buffer().readFrom(it) }
|
||||
if (isAnimatedAndSupported(imageSource) || !isTallImage(imageSource)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val bitmapRegionDecoder = getBitmapRegionDecoder(imageFile.openInputStream())
|
||||
val bitmapRegionDecoder = getBitmapRegionDecoder(imageSource.peek().inputStream())
|
||||
if (bitmapRegionDecoder == null) {
|
||||
logcat { "Failed to create new instance of BitmapRegionDecoder" }
|
||||
return false
|
||||
}
|
||||
|
||||
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
|
||||
val options = extractImageOptions(imageSource).apply {
|
||||
inJustDecodeBounds = false
|
||||
}
|
||||
|
||||
@@ -548,16 +542,9 @@ object ImageUtil {
|
||||
/**
|
||||
* Used to check an image's dimensions without loading it in the memory.
|
||||
*/
|
||||
private fun extractImageOptions(
|
||||
imageStream: InputStream,
|
||||
resetAfterExtraction: Boolean = true,
|
||||
): BitmapFactory.Options {
|
||||
imageStream.mark(Int.MAX_VALUE)
|
||||
|
||||
val imageBytes = imageStream.readBytes()
|
||||
private fun extractImageOptions(imageSource: BufferedSource): BitmapFactory.Options {
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options)
|
||||
if (resetAfterExtraction) imageStream.reset()
|
||||
BitmapFactory.decodeStream(imageSource.peek().inputStream(), null, options)
|
||||
return options
|
||||
}
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp_version = "8.3.1"
|
||||
agp_version = "8.3.2"
|
||||
lifecycle_version = "2.7.0"
|
||||
paging_version = "3.2.1"
|
||||
|
||||
@@ -10,7 +10,7 @@ annotation = "androidx.annotation:annotation:1.7.1"
|
||||
appcompat = "androidx.appcompat:appcompat:1.6.1"
|
||||
biometricktx = "androidx.biometric:biometric-ktx:1.2.0-alpha05"
|
||||
constraintlayout = "androidx.constraintlayout:constraintlayout:2.1.4"
|
||||
corektx = "androidx.core:core-ktx:1.12.0"
|
||||
corektx = "androidx.core:core-ktx:1.13.0"
|
||||
splashscreen = "androidx.core:core-splashscreen:1.0.1"
|
||||
recyclerview = "androidx.recyclerview:recyclerview:1.3.2"
|
||||
viewpager = "androidx.viewpager:viewpager:1.1.0-alpha01"
|
||||
@@ -25,7 +25,7 @@ workmanager = "androidx.work:work-runtime:2.9.0"
|
||||
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging_version" }
|
||||
paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging_version" }
|
||||
|
||||
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.3"
|
||||
benchmark-macro = "androidx.benchmark:benchmark-macro-junit4:1.2.4"
|
||||
test-ext = "androidx.test.ext:junit-ktx:1.2.0-alpha03"
|
||||
test-espresso-core = "androidx.test.espresso:espresso-core:3.6.0-alpha03"
|
||||
test-uiautomator = "androidx.test.uiautomator:uiautomator:2.3.0"
|
||||
|
@@ -1,12 +1,13 @@
|
||||
[versions]
|
||||
compiler = "1.5.11"
|
||||
compose-bom = "2024.02.00-alpha02"
|
||||
compiler = "1.5.12"
|
||||
# 2024.04.00-alpha01 has several bugs with the new animateItem() modifier
|
||||
compose-bom = "2024.03.00-alpha02"
|
||||
accompanist = "0.35.0-alpha"
|
||||
|
||||
[libraries]
|
||||
compiler = { module = "androidx.compose.compiler:compiler", version.ref = "compiler" }
|
||||
|
||||
activity = "androidx.activity:activity-compose:1.8.2"
|
||||
activity = "androidx.activity:activity-compose:1.9.0"
|
||||
bom = { group = "dev.chrisbanes.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
animation = { module = "androidx.compose.animation:animation" }
|
||||
|
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
aboutlib_version = "11.1.1"
|
||||
aboutlib_version = "11.1.3"
|
||||
leakcanary = "2.13"
|
||||
moko = "0.23.0"
|
||||
okhttp_version = "5.0.0-alpha.12"
|
||||
@@ -73,7 +73,7 @@ moko-gradle = { module = "dev.icerock.moko:resources-generator", version.ref = "
|
||||
|
||||
logcat = "com.squareup.logcat:logcat:0.1"
|
||||
|
||||
firebase-analytics = "com.google.firebase:firebase-analytics-ktx:21.6.1"
|
||||
firebase-analytics = "com.google.firebase:firebase-analytics-ktx:21.6.2"
|
||||
|
||||
aboutLibraries-gradle = { module = "com.mikepenz.aboutlibraries.plugin:aboutlibraries-plugin", version.ref = "aboutlib_version" }
|
||||
aboutLibraries-compose = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "aboutlib_version" }
|
||||
|
@@ -10,7 +10,6 @@ import androidx.compose.foundation.gestures.DraggableAnchors
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.anchoredDraggable
|
||||
import androidx.compose.foundation.gestures.animateTo
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
@@ -78,8 +77,7 @@ fun AdaptiveSheet(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
enabled = true,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = internalOnDismissRequest,
|
||||
)
|
||||
@@ -91,7 +89,7 @@ fun AdaptiveSheet(
|
||||
modifier = Modifier
|
||||
.requiredWidthIn(max = 460.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = {},
|
||||
)
|
||||
@@ -122,14 +120,14 @@ fun AdaptiveSheet(
|
||||
)
|
||||
}
|
||||
val internalOnDismissRequest = {
|
||||
if (anchoredDraggableState.currentValue == 0) {
|
||||
if (anchoredDraggableState.settledValue == 0) {
|
||||
scope.launch { anchoredDraggableState.animateTo(1) }
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = internalOnDismissRequest,
|
||||
)
|
||||
@@ -147,7 +145,7 @@ fun AdaptiveSheet(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 460.dp)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onClick = {},
|
||||
)
|
||||
@@ -192,7 +190,7 @@ fun AdaptiveSheet(
|
||||
|
||||
LaunchedEffect(anchoredDraggableState) {
|
||||
scope.launch { anchoredDraggableState.animateTo(0) }
|
||||
snapshotFlow { anchoredDraggableState.currentValue }
|
||||
snapshotFlow { anchoredDraggableState.settledValue }
|
||||
.drop(1)
|
||||
.filter { it == 1 }
|
||||
.collectLatest {
|
||||
|
@@ -6,13 +6,13 @@ import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.ripple
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.LocalAbsoluteTonalElevation
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.NonRestartableComposable
|
||||
|
@@ -1,7 +1,6 @@
|
||||
package tachiyomi.presentation.core.util
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.isImeVisible
|
||||
@@ -42,14 +41,12 @@ fun Modifier.secondaryItemAlpha(): Modifier = this.alpha(SecondaryItemAlpha)
|
||||
fun Modifier.clickableNoIndication(
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
onClick: () -> Unit,
|
||||
): Modifier = composed {
|
||||
Modifier.combinedClickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
) = this.combinedClickable(
|
||||
interactionSource = null,
|
||||
indication = null,
|
||||
onLongClick = onLongClick,
|
||||
onClick = onClick,
|
||||
)
|
||||
|
||||
/**
|
||||
* For TextField, the provided [action] will be invoked when
|
||||
|
Reference in New Issue
Block a user