Refactor image loading
parent
bd4fecc3b6
commit
10bd46f077
@ -0,0 +1,192 @@
|
|||||||
|
package org.koitharu.kotatsu.core.image
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.request.Disposable
|
||||||
|
import coil3.request.ErrorResult
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.NullRequestData
|
||||||
|
import coil3.request.SuccessResult
|
||||||
|
import coil3.request.allowRgb565
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.request.lifecycle
|
||||||
|
import coil3.request.target
|
||||||
|
import coil3.size.Scale
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.size.SizeResolver
|
||||||
|
import coil3.size.ViewSizeResolver
|
||||||
|
import coil3.util.CoilUtils
|
||||||
|
import com.google.android.material.imageview.ShapeableImageView
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
import java.util.LinkedList
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
open class CoilImageView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : ShapeableImageView(context, attrs, defStyleAttr), ImageRequest.Listener {
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var coil: ImageLoader
|
||||||
|
|
||||||
|
var allowRgb565: Boolean = false
|
||||||
|
var useExistingDrawable: Boolean = false
|
||||||
|
var decodeRegion: Boolean = false
|
||||||
|
var exactImageSize: Size? = null
|
||||||
|
var crossfadeDurationFactor: Float = 1f
|
||||||
|
|
||||||
|
var placeholderDrawable: Drawable? = null
|
||||||
|
var errorDrawable: Drawable? = null
|
||||||
|
var fallbackDrawable: Drawable? = null
|
||||||
|
|
||||||
|
private var currentRequest: Disposable? = null
|
||||||
|
private var currentImageData: Any = NullRequestData
|
||||||
|
|
||||||
|
private var listeners: MutableList<ImageRequest.Listener>? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.CoilImageView, defStyleAttr) {
|
||||||
|
allowRgb565 = getBoolean(R.styleable.CoilImageView_allowRgb565, allowRgb565)
|
||||||
|
useExistingDrawable = getBoolean(R.styleable.CoilImageView_useExistingDrawable, useExistingDrawable)
|
||||||
|
decodeRegion = getBoolean(R.styleable.CoilImageView_decodeRegion, decodeRegion)
|
||||||
|
placeholderDrawable = getDrawable(R.styleable.CoilImageView_placeholderDrawable)
|
||||||
|
errorDrawable = getDrawable(R.styleable.CoilImageView_errorDrawable)
|
||||||
|
fallbackDrawable = getDrawable(R.styleable.CoilImageView_fallbackDrawable)
|
||||||
|
crossfadeDurationFactor = if (getBoolean(R.styleable.CoilImageView_crossfadeEnabled, true)) {
|
||||||
|
crossfadeDurationFactor
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(request: ImageRequest) {
|
||||||
|
super.onCancel(request)
|
||||||
|
listeners?.forEach { it.onCancel(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(request: ImageRequest, result: ErrorResult) {
|
||||||
|
super.onError(request, result)
|
||||||
|
listeners?.forEach { it.onError(request, result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart(request: ImageRequest) {
|
||||||
|
super.onStart(request)
|
||||||
|
listeners?.forEach { it.onStart(request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSuccess(request: ImageRequest, result: SuccessResult) {
|
||||||
|
super.onSuccess(request, result)
|
||||||
|
listeners?.forEach { it.onSuccess(request, result) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addImageRequestListener(listener: ImageRequest.Listener) {
|
||||||
|
val list = listeners ?: LinkedList<ImageRequest.Listener>().also { listeners = it }
|
||||||
|
list.add(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeImageRequestListener(listener: ImageRequest.Listener) {
|
||||||
|
listeners?.remove(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setImageAsync(@DrawableRes resourceId: Int) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(resourceId)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(url: String?) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(url)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Deprecated("Use more specific overrides instead")
|
||||||
|
fun setImageAsync(request: ImageRequest) = enqueueRequest(
|
||||||
|
request.newBuilder()
|
||||||
|
.lifecycle(request.lifecycle ?: findViewTreeLifecycleOwner()?.lifecycle)
|
||||||
|
.target(this)
|
||||||
|
.size(
|
||||||
|
if (request.sizeResolver == SizeResolver.ORIGINAL) {
|
||||||
|
ViewSizeResolver(this)
|
||||||
|
} else {
|
||||||
|
request.sizeResolver
|
||||||
|
},
|
||||||
|
).build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun disposeImage() {
|
||||||
|
CoilUtils.dispose(this)
|
||||||
|
currentRequest = null
|
||||||
|
currentImageData = NullRequestData
|
||||||
|
setImageDrawable(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun enqueueRequest(request: ImageRequest): Disposable {
|
||||||
|
val previous = currentRequest
|
||||||
|
if (currentImageData == request.data && previous?.job?.isCancelled == false) {
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
currentImageData = request.data
|
||||||
|
return coil.enqueue(request).also { currentRequest = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun newRequestBuilder() = ImageRequest.Builder(context).apply {
|
||||||
|
lifecycle(findViewTreeLifecycleOwner())
|
||||||
|
val crossfadeDuration = if (context.isAnimationsEnabled) {
|
||||||
|
(context.getAnimationDuration(R.integer.config_defaultAnimTime) * crossfadeDurationFactor).toInt()
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
crossfade(crossfadeDuration)
|
||||||
|
if (useExistingDrawable) {
|
||||||
|
val previousDrawable = this@CoilImageView.drawable?.asImage()
|
||||||
|
if (previousDrawable != null) {
|
||||||
|
fallback(previousDrawable)
|
||||||
|
placeholder(previousDrawable)
|
||||||
|
error(previousDrawable)
|
||||||
|
} else {
|
||||||
|
setupPlaceholders()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setupPlaceholders()
|
||||||
|
}
|
||||||
|
if (decodeRegion) {
|
||||||
|
decodeRegion(0)
|
||||||
|
}
|
||||||
|
size(
|
||||||
|
exactImageSize?.let {
|
||||||
|
SizeResolver(it)
|
||||||
|
} ?: ViewSizeResolver(this@CoilImageView),
|
||||||
|
)
|
||||||
|
scale(scaleType.toCoilScale())
|
||||||
|
listener(this@CoilImageView)
|
||||||
|
allowRgb565(allowRgb565)
|
||||||
|
target(this@CoilImageView)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ImageRequest.Builder.setupPlaceholders() {
|
||||||
|
placeholder(placeholderDrawable?.asImage())
|
||||||
|
error(errorDrawable?.asImage())
|
||||||
|
fallback(fallbackDrawable?.asImage())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ScaleType.toCoilScale(): Scale = if (this == ScaleType.CENTER_CROP) {
|
||||||
|
Scale.FILL
|
||||||
|
} else {
|
||||||
|
Scale.FIT
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,105 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.image
|
|
||||||
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.view.ViewTreeObserver
|
|
||||||
import android.view.ViewTreeObserver.OnPreDrawListener
|
|
||||||
import android.widget.ImageView
|
|
||||||
import coil3.size.Dimension
|
|
||||||
import coil3.size.Size
|
|
||||||
import coil3.size.ViewSizeResolver
|
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
|
||||||
import kotlin.coroutines.resume
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private const val ASPECT_RATIO_HEIGHT = 18f
|
|
||||||
private const val ASPECT_RATIO_WIDTH = 13f
|
|
||||||
|
|
||||||
class CoverSizeResolver(
|
|
||||||
override val view: ImageView,
|
|
||||||
) : ViewSizeResolver<ImageView> {
|
|
||||||
|
|
||||||
override suspend fun size(): Size {
|
|
||||||
// Fast path: the view is already measured.
|
|
||||||
getSize()?.let { return it }
|
|
||||||
|
|
||||||
// Slow path: wait for the view to be measured.
|
|
||||||
return suspendCancellableCoroutine { continuation ->
|
|
||||||
val viewTreeObserver = view.viewTreeObserver
|
|
||||||
|
|
||||||
val preDrawListener = object : OnPreDrawListener {
|
|
||||||
private var isResumed = false
|
|
||||||
|
|
||||||
override fun onPreDraw(): Boolean {
|
|
||||||
val size = getSize()
|
|
||||||
if (size != null) {
|
|
||||||
viewTreeObserver.removePreDrawListenerSafe(this)
|
|
||||||
|
|
||||||
if (!isResumed) {
|
|
||||||
isResumed = true
|
|
||||||
continuation.resume(size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
|
||||||
|
|
||||||
continuation.invokeOnCancellation {
|
|
||||||
viewTreeObserver.removePreDrawListenerSafe(preDrawListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSize(): Size? {
|
|
||||||
var width = getWidth()
|
|
||||||
var height = getHeight()
|
|
||||||
when {
|
|
||||||
width == null && height == null -> {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
height == null && width != null -> {
|
|
||||||
height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt())
|
|
||||||
}
|
|
||||||
width == null && height != null -> {
|
|
||||||
width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Size(checkNotNull(width), checkNotNull(height))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getWidth() = getDimension(
|
|
||||||
paramSize = view.layoutParams?.width ?: -1,
|
|
||||||
viewSize = view.width,
|
|
||||||
paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun getHeight() = getDimension(
|
|
||||||
paramSize = view.layoutParams?.height ?: -1,
|
|
||||||
viewSize = view.height,
|
|
||||||
paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
|
|
||||||
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
val insetParamSize = paramSize - paddingSize
|
|
||||||
if (insetParamSize > 0) {
|
|
||||||
return Dimension(insetParamSize)
|
|
||||||
}
|
|
||||||
val insetViewSize = viewSize - paddingSize
|
|
||||||
if (insetViewSize > 0) {
|
|
||||||
return Dimension(insetViewSize)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) {
|
|
||||||
if (isAlive) {
|
|
||||||
removeOnPreDrawListener(victim)
|
|
||||||
} else {
|
|
||||||
view.viewTreeObserver.removeOnPreDrawListener(victim)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.image
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.StyleRes
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import coil3.Image
|
||||||
|
import coil3.asImage
|
||||||
|
import coil3.request.Disposable
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier.Companion.ignoreCaptchaErrors
|
||||||
|
import org.koitharu.kotatsu.core.image.CoilImageView
|
||||||
|
import org.koitharu.kotatsu.core.parser.favicon.faviconUri
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class FaviconView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : CoilImageView(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
@StyleRes
|
||||||
|
private var iconStyle: Int = R.style.FaviconDrawable
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.FaviconView, defStyleAttr) {
|
||||||
|
iconStyle = getResourceId(R.styleable.FaviconView_iconStyle, iconStyle)
|
||||||
|
}
|
||||||
|
if (isInEditMode) {
|
||||||
|
setImageDrawable(
|
||||||
|
FaviconDrawable(
|
||||||
|
context = context,
|
||||||
|
styleResId = iconStyle,
|
||||||
|
name = context.getString(R.string.app_name).random().toString(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setImageAsync(mangaSource: MangaSource): Disposable {
|
||||||
|
val fallbackFactory: (ImageRequest) -> Image? = {
|
||||||
|
FaviconDrawable(context, iconStyle, mangaSource.name).asImage()
|
||||||
|
}
|
||||||
|
val placeholderFactory: (ImageRequest) -> Image? = if (context.isAnimationsEnabled) {
|
||||||
|
{ AnimatedFaviconDrawable(context, iconStyle, mangaSource.name).asImage() }
|
||||||
|
} else {
|
||||||
|
fallbackFactory
|
||||||
|
}
|
||||||
|
return enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(mangaSource.faviconUri())
|
||||||
|
.error(fallbackFactory)
|
||||||
|
.fallback(fallbackFactory)
|
||||||
|
.placeholder(placeholderFactory)
|
||||||
|
.mangaSourceExtra(mangaSource)
|
||||||
|
.ignoreCaptchaErrors()
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,43 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.ui.widgets
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.AttributeSet
|
|
||||||
import android.widget.LinearLayout.HORIZONTAL
|
|
||||||
import android.widget.LinearLayout.VERTICAL
|
|
||||||
import androidx.annotation.AttrRes
|
|
||||||
import androidx.core.content.withStyledAttributes
|
|
||||||
import com.google.android.material.imageview.ShapeableImageView
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private const val ASPECT_RATIO_HEIGHT = 3f
|
|
||||||
private const val ASPECT_RATIO_WIDTH = 2f
|
|
||||||
|
|
||||||
class CoverImageView @JvmOverloads constructor(
|
|
||||||
context: Context,
|
|
||||||
attrs: AttributeSet? = null,
|
|
||||||
@AttrRes defStyleAttr: Int = 0,
|
|
||||||
) : ShapeableImageView(context, attrs, defStyleAttr) {
|
|
||||||
|
|
||||||
private var orientation: Int = HORIZONTAL
|
|
||||||
|
|
||||||
init {
|
|
||||||
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
|
|
||||||
orientation = getInt(R.styleable.CoverImageView_android_orientation, orientation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
|
||||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
|
||||||
val desiredWidth: Int
|
|
||||||
val desiredHeight: Int
|
|
||||||
if (orientation == VERTICAL) {
|
|
||||||
desiredHeight = measuredHeight
|
|
||||||
desiredWidth = (desiredHeight * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()
|
|
||||||
} else {
|
|
||||||
desiredWidth = measuredWidth
|
|
||||||
desiredHeight = (desiredWidth * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()
|
|
||||||
}
|
|
||||||
setMeasuredDimension(desiredWidth, desiredHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
package org.koitharu.kotatsu.core.ui.widgets
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.core.view.isEmpty
|
||||||
|
import androidx.core.view.isGone
|
||||||
|
|
||||||
|
open class StackLayout @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : ViewGroup(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private val visibleChildren = ArrayList<View>()
|
||||||
|
|
||||||
|
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
||||||
|
val w = r - l - paddingLeft - paddingRight
|
||||||
|
val h = b - t - paddingTop - paddingBottom
|
||||||
|
visibleChildren.clear()
|
||||||
|
children.filterNotTo(visibleChildren) { it.isGone }
|
||||||
|
if (w <= 0 || h <= 0 || visibleChildren.isEmpty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val xStep = w / (visibleChildren.size + 1)
|
||||||
|
val yStep = h / (visibleChildren.size + 1)
|
||||||
|
val maxW = w
|
||||||
|
val maxH = h
|
||||||
|
val total = visibleChildren.size
|
||||||
|
for ((index, child) in visibleChildren.withIndex()) {
|
||||||
|
var cx = paddingLeft + xStep * (total - index)
|
||||||
|
var cy = paddingTop + yStep * (index + 1)
|
||||||
|
val rx = child.measuredWidth.coerceAtMost(maxW) / 2
|
||||||
|
val ry = child.measuredHeight.coerceAtMost(maxH) / 2
|
||||||
|
if (cx < rx) {
|
||||||
|
cx = rx
|
||||||
|
}
|
||||||
|
if (cy < ry) {
|
||||||
|
cy = ry
|
||||||
|
}
|
||||||
|
if (cx + rx > width) {
|
||||||
|
cx = width - rx
|
||||||
|
}
|
||||||
|
if (cy + ry > height) {
|
||||||
|
cy = height - ry
|
||||||
|
}
|
||||||
|
child.layout(cx - rx, cy - ry, cx + rx, cy + ry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
|
measureChildren(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
if (isEmpty()) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var h = 0
|
||||||
|
var w = 0
|
||||||
|
for (i in 0 until childCount) {
|
||||||
|
val child = getChildAt(i)
|
||||||
|
if (child.isGone) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val mw = child.measuredWidth
|
||||||
|
val mh = child.measuredHeight
|
||||||
|
if (h == 0 || w == 0) {
|
||||||
|
h = mh
|
||||||
|
w = mw
|
||||||
|
} else {
|
||||||
|
h += mh / 2
|
||||||
|
w += mw / 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h += paddingTop + paddingBottom
|
||||||
|
w += paddingLeft + paddingRight
|
||||||
|
setMeasuredDimension(
|
||||||
|
resolveSizeAndState(w, widthMeasureSpec, 0),
|
||||||
|
resolveSizeAndState(h, heightMeasureSpec, 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.view.ViewTreeObserver.OnPreDrawListener
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import coil3.request.transformations
|
||||||
|
import coil3.size.Dimension
|
||||||
|
import coil3.size.Size
|
||||||
|
import coil3.size.ViewSizeResolver
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.bookmarks.domain.Bookmark
|
||||||
|
import org.koitharu.kotatsu.core.image.CoilImageView
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.AnimatedPlaceholderDrawable
|
||||||
|
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.bookmarkExtra
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.decodeRegion
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaExtra
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
|
||||||
|
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaPage
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
||||||
|
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
import com.google.android.material.R as materialR
|
||||||
|
|
||||||
|
class CoverImageView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = R.attr.coverImageViewStyle,
|
||||||
|
) : CoilImageView(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private var aspectRationHeight: Int = 0
|
||||||
|
private var aspectRationWidth: Int = 0
|
||||||
|
var trimImage: Boolean = false
|
||||||
|
|
||||||
|
private val hasAspectRatio: Boolean
|
||||||
|
get() = aspectRationHeight > 0 && aspectRationWidth > 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.CoverImageView, defStyleAttr) {
|
||||||
|
aspectRationHeight = getInt(R.styleable.CoverImageView_aspectRationHeight, aspectRationHeight)
|
||||||
|
aspectRationWidth = getInt(R.styleable.CoverImageView_aspectRationWidth, aspectRationWidth)
|
||||||
|
trimImage = getBoolean(R.styleable.CoverImageView_trimImage, trimImage)
|
||||||
|
}
|
||||||
|
if (placeholderDrawable == null) {
|
||||||
|
placeholderDrawable = AnimatedPlaceholderDrawable(context)
|
||||||
|
}
|
||||||
|
if (errorDrawable == null) {
|
||||||
|
errorDrawable = ColorUtils.blendARGB(
|
||||||
|
context.getThemeColor(materialR.attr.colorErrorContainer),
|
||||||
|
context.getThemeColor(appcompatR.attr.colorBackgroundFloating),
|
||||||
|
0.25f,
|
||||||
|
).toDrawable()
|
||||||
|
}
|
||||||
|
if (fallbackDrawable == null) {
|
||||||
|
fallbackDrawable = context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
if (!hasAspectRatio) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isExactWidth = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
|
||||||
|
val isExactHeight = MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY
|
||||||
|
when {
|
||||||
|
isExactHeight && isExactWidth -> Unit
|
||||||
|
isExactHeight -> setMeasuredDimension(
|
||||||
|
/* measuredWidth = */ measuredHeight * aspectRationWidth / aspectRationHeight,
|
||||||
|
/* measuredHeight = */ measuredHeight,
|
||||||
|
)
|
||||||
|
|
||||||
|
isExactWidth -> setMeasuredDimension(
|
||||||
|
/* measuredWidth = */ measuredWidth,
|
||||||
|
/* measuredHeight = */ measuredWidth * aspectRationHeight / aspectRationWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setImageAsync(page: ReaderPage) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(page.preview?.nullIfEmpty() ?: page.toMangaPage())
|
||||||
|
.mangaSourceExtra(page.source)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(page: MangaPage) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(page.preview?.nullIfEmpty() ?: page)
|
||||||
|
.mangaSourceExtra(page.source)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(cover: Cover?) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(cover?.url)
|
||||||
|
.mangaSourceExtra(cover?.mangaSource)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(
|
||||||
|
coverUrl: String?,
|
||||||
|
manga: Manga?,
|
||||||
|
) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(coverUrl)
|
||||||
|
.mangaExtra(manga)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(
|
||||||
|
coverUrl: String?,
|
||||||
|
source: MangaSource,
|
||||||
|
) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(coverUrl)
|
||||||
|
.mangaSourceExtra(source)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun setImageAsync(
|
||||||
|
bookmark: Bookmark
|
||||||
|
) = enqueueRequest(
|
||||||
|
newRequestBuilder()
|
||||||
|
.data(bookmark.imageLoadData)
|
||||||
|
.decodeRegion(bookmark.scroll)
|
||||||
|
.bookmarkExtra(bookmark)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun newRequestBuilder() = super.newRequestBuilder().apply {
|
||||||
|
if (trimImage) {
|
||||||
|
transformations(listOf(TrimTransformation()))
|
||||||
|
}
|
||||||
|
if (hasAspectRatio) {
|
||||||
|
size(CoverSizeResolver(this@CoverImageView))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class CoverSizeResolver(
|
||||||
|
override val view: CoverImageView,
|
||||||
|
) : ViewSizeResolver<CoverImageView> {
|
||||||
|
|
||||||
|
override suspend fun size(): Size {
|
||||||
|
// Fast path: the view is already measured.
|
||||||
|
getSize()?.let { return it }
|
||||||
|
|
||||||
|
// Slow path: wait for the view to be measured.
|
||||||
|
return suspendCancellableCoroutine { continuation ->
|
||||||
|
val viewTreeObserver = view.viewTreeObserver
|
||||||
|
|
||||||
|
val preDrawListener = object : OnPreDrawListener {
|
||||||
|
private var isResumed = false
|
||||||
|
|
||||||
|
override fun onPreDraw(): Boolean {
|
||||||
|
val size = getSize()
|
||||||
|
if (size != null) {
|
||||||
|
viewTreeObserver.removePreDrawListenerSafe(this)
|
||||||
|
|
||||||
|
if (!isResumed) {
|
||||||
|
isResumed = true
|
||||||
|
continuation.resume(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
viewTreeObserver.addOnPreDrawListener(preDrawListener)
|
||||||
|
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
viewTreeObserver.removePreDrawListenerSafe(preDrawListener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSize(): Size? {
|
||||||
|
var width = getWidth()
|
||||||
|
var height = getHeight()
|
||||||
|
when {
|
||||||
|
width == null && height == null -> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
height == null -> {
|
||||||
|
height = Dimension(width!!.px * view.aspectRationHeight / view.aspectRationWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
width == null -> {
|
||||||
|
width = Dimension(height.px * view.aspectRationWidth / view.aspectRationHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Size(checkNotNull(width), checkNotNull(height))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWidth() = getDimension(
|
||||||
|
paramSize = view.layoutParams?.width ?: -1,
|
||||||
|
viewSize = view.width,
|
||||||
|
paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getHeight() = getDimension(
|
||||||
|
paramSize = view.layoutParams?.height ?: -1,
|
||||||
|
viewSize = view.height,
|
||||||
|
paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? {
|
||||||
|
if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val insetParamSize = paramSize - paddingSize
|
||||||
|
if (insetParamSize > 0) {
|
||||||
|
return Dimension(insetParamSize)
|
||||||
|
}
|
||||||
|
val insetViewSize = viewSize - paddingSize
|
||||||
|
if (insetViewSize > 0) {
|
||||||
|
return Dimension(insetViewSize)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) {
|
||||||
|
if (isAlive) {
|
||||||
|
removeOnPreDrawListener(victim)
|
||||||
|
} else {
|
||||||
|
view.viewTreeObserver.removeOnPreDrawListener(victim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package org.koitharu.kotatsu.image.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import androidx.annotation.AttrRes
|
||||||
|
import androidx.annotation.Px
|
||||||
|
import androidx.core.content.withStyledAttributes
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import androidx.core.widget.ImageViewCompat
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.model.UnknownMangaSource
|
||||||
|
import org.koitharu.kotatsu.core.ui.widgets.StackLayout
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getThemeColor
|
||||||
|
import org.koitharu.kotatsu.databinding.ViewCoverStackBinding
|
||||||
|
import org.koitharu.kotatsu.favourites.domain.model.Cover
|
||||||
|
import org.koitharu.kotatsu.parsers.model.Manga
|
||||||
|
import org.koitharu.kotatsu.parsers.model.MangaSource
|
||||||
|
|
||||||
|
class CoverStackView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
@AttrRes defStyleAttr: Int = 0,
|
||||||
|
) : StackLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private val binding = ViewCoverStackBinding.inflate(LayoutInflater.from(context), this)
|
||||||
|
private val coverViews = arrayOf(
|
||||||
|
binding.imageViewCover1,
|
||||||
|
binding.imageViewCover2,
|
||||||
|
binding.imageViewCover3,
|
||||||
|
)
|
||||||
|
private var hideEmptyView: Boolean = true
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.withStyledAttributes(attrs, R.styleable.CoverStackView, defStyleAttr) {
|
||||||
|
val coverSize = getDimension(R.styleable.CoverStackView_coverSize, 0f)
|
||||||
|
if (coverSize > 0f) {
|
||||||
|
setCoverSize(coverSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val backgroundColor = context.getThemeColor(android.R.attr.colorBackground)
|
||||||
|
ImageViewCompat.setImageTintList(
|
||||||
|
binding.imageViewCover3,
|
||||||
|
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)),
|
||||||
|
)
|
||||||
|
ImageViewCompat.setImageTintList(
|
||||||
|
binding.imageViewCover2,
|
||||||
|
ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)),
|
||||||
|
)
|
||||||
|
binding.imageViewCover2.backgroundTintList = ColorStateList.valueOf(
|
||||||
|
ColorUtils.setAlphaComponent(backgroundColor, 76),
|
||||||
|
)
|
||||||
|
binding.imageViewCover3.backgroundTintList = ColorStateList.valueOf(
|
||||||
|
ColorUtils.setAlphaComponent(backgroundColor, 153),
|
||||||
|
)
|
||||||
|
coverViews.forEachIndexed { index, view ->
|
||||||
|
view.crossfadeDurationFactor = index + 1f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCoversAsync(covers: List<Cover>) {
|
||||||
|
coverViews.forEachIndexed { index, view ->
|
||||||
|
view.setImageAsync(covers.getOrNull(index))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmName("setMangaCoversAsync")
|
||||||
|
fun setCoversAsync(manga: List<Manga>) {
|
||||||
|
coverViews.forEachIndexed { index, view ->
|
||||||
|
val m = manga.getOrNull(index)
|
||||||
|
view.setCoverOrHide(m?.coverUrl, m, m?.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCoverSize(@Px coverSize: Float) {
|
||||||
|
val coverWidth = (coverSize * 13f).toInt()
|
||||||
|
val coverHeight = (coverSize * 18f).toInt()
|
||||||
|
children.forEach {
|
||||||
|
it.updateLayoutParams {
|
||||||
|
width = coverWidth
|
||||||
|
height = coverHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun CoverImageView.setCoverOrHide(url: String?, manga: Manga?, source: MangaSource?) {
|
||||||
|
if (url.isNullOrEmpty() && hideEmptyView) {
|
||||||
|
disposeImage()
|
||||||
|
isVisible = false
|
||||||
|
} else {
|
||||||
|
isVisible = true
|
||||||
|
if (manga != null) {
|
||||||
|
setImageAsync(url, manga)
|
||||||
|
} else {
|
||||||
|
setImageAsync(url, source ?: UnknownMangaSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,18 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.reader.ui.colorfilter
|
|
||||||
|
|
||||||
import android.graphics.drawable.Drawable
|
|
||||||
import android.widget.ImageView
|
|
||||||
import coil3.target.ImageViewTarget
|
|
||||||
|
|
||||||
class DoubleViewTarget(
|
|
||||||
primaryView: ImageView,
|
|
||||||
private val secondaryView: ImageView,
|
|
||||||
) : ImageViewTarget(primaryView) {
|
|
||||||
|
|
||||||
override var drawable: Drawable?
|
|
||||||
get() = super.drawable
|
|
||||||
set(value) {
|
|
||||||
super.drawable = value
|
|
||||||
secondaryView.setImageDrawable(value?.constantState?.newDrawable())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
style="@style/Widget.Material3.CardView.Outlined"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="@dimen/bookmark_item_height"
|
|
||||||
app:cardCornerRadius="12dp">
|
|
||||||
|
|
||||||
<org.koitharu.kotatsu.core.ui.widgets.CoverImageView
|
|
||||||
android:id="@+id/imageView_thumb"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:scaleType="centerCrop"
|
|
||||||
tools:src="@drawable/ic_placeholder" />
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue