Compare commits

...

9 Commits

Author SHA1 Message Date
f61e5472e1 feat: 没有 thumbhash 值时回落到默认占位图 https://jira.shanqu.cc/browse/GHZSCY-6872 2025-02-10 16:44:02 +08:00
a4548a7cdb feat: 占位图显示优化(gitlab-ci) https://jira.shanqu.cc/browse/GHZSCY-6872
Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
2025-02-10 16:44:02 +08:00
7171b6d14c feat: 移除测试数据
Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
2025-02-10 16:44:02 +08:00
878581b83f feat: 移除默认占位图
Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
2025-02-10 16:44:02 +08:00
2ef8dcbdfd fix: 调整 hash 摘取实现 2025-02-10 16:44:02 +08:00
7b0fe13804 fix: blurhash 调用问题 2025-02-10 16:44:02 +08:00
08d56959de feat: 将实现切换至 thumbHash
Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
2025-02-10 16:44:02 +08:00
239caac46a feat: 简单优化 blurHash 方案
Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
2025-02-10 16:44:02 +08:00
830e79dc57 feat: 本地模拟 encode 模糊
Signed-off-by: chenjuntao <chenjuntao@ghzhushou.com>
2025-02-10 16:44:02 +08:00
20 changed files with 527 additions and 37 deletions

View File

@ -72,6 +72,7 @@ android_build:
only:
- dev
- release
- feat/GHZSCY-6872
# 代码检查
sonarqube_analysis:
@ -157,3 +158,4 @@ oss-upload&send-email:
only:
- dev
- release
- feat/GHZSCY-6872

View File

@ -65,7 +65,7 @@ class NewInstalledGameFragmentAdapter(context: Context, private var mViewModel:
if (drawable != null && (drawable.intrinsicWidth >= 300 || drawable.intrinsicHeight >= 300)) {
drawable = drawable.toBitmap(200, 200).toDrawable(mContext.resources)
}
binding.gameIconView.getIconIv().hierarchy.setPlaceholderImage(drawable)
// binding.gameIconView.getIconIv().hierarchy.setPlaceholderImage(drawable)
binding.gameIconView.getIconDecoratorIv().visibility = View.GONE
if (isSimulatorGame(gameEntity)) {
binding.gameDes.text = String.format("V%s", gameEntity.getApk()[0].version)

View File

@ -1026,7 +1026,6 @@ class ForumDetailFragment : BaseLazyTabFragment(), IScrollable {
.setFadeDuration(500)
.setRoundingParams(roundingParams)
.setPressedStateOverlay(ColorDrawable(ContextCompat.getColor(requireContext(), com.gh.gamecenter.common.R.color.pressed_bg)))
.setPlaceholderImage(com.gh.gamecenter.common.R.drawable.occupy2, ScalingUtils.ScaleType.CENTER)
.setBackground(ColorDrawable(ContextCompat.getColor(requireContext(), com.gh.gamecenter.common.R.color.placeholder_bg)))
.setActualImageScaleType(ScalingUtils.ScaleType.CENTER_CROP)
.build()

View File

@ -153,11 +153,10 @@ class HomeGameItemViewHolder(val binding: HomeGameItemBinding) : BaseRecyclerVie
binding.adLabelTv
)
MiniGameItemHelper.setMiniGameUsage(binding.gamePlayCount, game)
val hierarchy = binding.gameImage.hierarchy
try {
hierarchy.setPlaceholderImage(ColorDrawable(game.homeSetting.placeholderColor.hexStringToIntColor()))
binding.gameImage.withFallbackPlaceholder(ColorDrawable(game.homeSetting.placeholderColor.hexStringToIntColor()))
} catch (ignore: Throwable) {
hierarchy.setPlaceholderImage(RandomUtils.getRandomPlaceholderColor())
// ignored
}
DownloadItemUtils.setOnClickListener(

View File

@ -118,11 +118,10 @@ class CustomHomeGameItemViewHolder(
} else {
ImageUtils.display(binding.gameImage, game.homeSetting.image)
}
val hierarchy = binding.gameImage.hierarchy
try {
hierarchy.setPlaceholderImage(ColorDrawable(game.homeSetting.placeholderColor.hexStringToIntColor()))
binding.gameImage.withFallbackPlaceholder(ColorDrawable(game.homeSetting.placeholderColor.hexStringToIntColor()))
} catch (ignore: Throwable) {
hierarchy.setPlaceholderImage(RandomUtils.getRandomPlaceholderColor())
// ignored
}
}
binding.autoVideoView.goneIf(game.showImage) {

View File

@ -50,11 +50,10 @@ class CustomHomeHorizontalSlideVideoItemViewHolder(
binding.autoVideoView.thumbImage.setTag(ImageUtils.TAG_TARGET_WIDTH, 280F.dip2px())
binding.gameImage.goneIf(!gameEntity.showImage) {
ImageUtils.display(binding.gameImage, gameEntity.homeSetting.image)
val hierarchy = binding.gameImage.hierarchy
try {
hierarchy.setPlaceholderImage(ColorDrawable(gameEntity.homeSetting.placeholderColor.hexStringToIntColor()))
binding.gameImage.withFallbackPlaceholder(ColorDrawable(gameEntity.homeSetting.placeholderColor.hexStringToIntColor()))
} catch (ignore: Throwable) {
hierarchy.setPlaceholderImage(RandomUtils.getRandomPlaceholderColor())
// ignored
}
}
binding.autoVideoView.goneIf(gameEntity.showImage || gameEntity.topVideo == null) {

View File

@ -10,7 +10,9 @@ import com.gh.gamecenter.common.base.BaseRecyclerViewHolder
import com.gh.gamecenter.common.utils.ImageUtils
import com.gh.gamecenter.common.utils.dip2px
import com.gh.gamecenter.common.utils.goneIf
import com.gh.gamecenter.common.utils.hexStringToIntColor
import com.gh.gamecenter.common.utils.toColor
import com.gh.gamecenter.common.utils.withFallbackPlaceholder
import com.gh.gamecenter.core.utils.RandomUtils
import com.gh.gamecenter.databinding.HomeSlideListItemCustomBinding
import com.gh.gamecenter.entity.HomeSlide
@ -61,11 +63,10 @@ class CustomHomeSlideListItemViewHolder(val binding: HomeSlideListItemCustomBind
}
)
val hierarchy = binding.slideBackground.hierarchy
try {
hierarchy.setPlaceholderImage(ColorDrawable(Color.parseColor(homeSlide.placeholderColor)))
binding.slideBackground.withFallbackPlaceholder(ColorDrawable(homeSlide.placeholderColor.hexStringToIntColor()))
} catch (ignore: Throwable) {
hierarchy.setPlaceholderImage(RandomUtils.getRandomPlaceholderColor())
// ignored
}
val linkGame = homeSlide.linkGame ?: return

View File

@ -12,6 +12,7 @@ import com.facebook.imagepipeline.image.ImageInfo
import com.gh.gamecenter.R
import com.gh.gamecenter.common.base.BaseRecyclerViewHolder
import com.gh.gamecenter.common.utils.*
import com.gh.gamecenter.common.utils.ShareUtils.ShareEntrance.game
import com.gh.gamecenter.core.utils.DisplayUtils
import com.gh.gamecenter.core.utils.RandomUtils
import com.gh.gamecenter.databinding.HomeSubSlideListItemCustomBinding
@ -77,11 +78,10 @@ class CustomHomeSubSlideListItemViewHolder(val binding: HomeSubSlideListItemCust
}
)
val hierarchy = binding.slideBackground.hierarchy
try {
hierarchy.setPlaceholderImage(ColorDrawable(Color.parseColor(homeSlide.placeholderColor)))
binding.slideBackground.withFallbackPlaceholder(ColorDrawable(homeSlide.placeholderColor.hexStringToIntColor()))
} catch (ignore: Throwable) {
hierarchy.setPlaceholderImage(RandomUtils.getRandomPlaceholderColor())
// ignored
}
}
}

View File

@ -33,11 +33,10 @@ class HomeHorizontalSlideVideoItemViewHolder(val binding: ItemHomeHorizontalSlid
binding.autoVideoView.thumbImage.setTag(ImageUtils.TAG_TARGET_WIDTH, 280F.dip2px())
binding.gameImage.goneIf(gameEntity.topVideo != null || gameEntity.homeSetting.image.isEmpty()) {
ImageUtils.display(binding.gameImage, gameEntity.homeSetting.image)
val hierarchy = binding.gameImage.hierarchy
try {
hierarchy.setPlaceholderImage(ColorDrawable(gameEntity.homeSetting.placeholderColor.hexStringToIntColor()))
binding.gameImage.withFallbackPlaceholder(ColorDrawable(gameEntity.homeSetting.placeholderColor.hexStringToIntColor()))
} catch (ignore: Throwable) {
hierarchy.setPlaceholderImage(RandomUtils.getRandomPlaceholderColor())
// ignored
}
}
binding.autoVideoView.goneIf(gameEntity.topVideo == null) {

View File

@ -21,6 +21,7 @@ import com.gh.gamecenter.common.constant.Constants
import com.gh.gamecenter.common.utils.ImageUtils
import com.gh.gamecenter.common.utils.countDownTimer
import com.gh.gamecenter.common.utils.dip2px
import com.gh.gamecenter.common.utils.disableThumbHash
import com.gh.gamecenter.common.utils.rxTimer
import com.gh.gamecenter.common.view.DrawableView
import com.gh.gamecenter.common.view.RadiusCardView
@ -378,6 +379,7 @@ class AutomaticVideoView @JvmOverloads constructor(context: Context, attrs: Attr
}
fun updateThumb(url: String) {
thumbImage.disableThumbHash()
ImageUtils.display(thumbImage, url)
}

View File

@ -77,7 +77,6 @@ abstract class BaseBottomTabFragment<T : ViewBinding> : ToolbarFragment() {
} else {
ImageUtils.picasso
.load(Uri.parse(if (bottomTab.default) bottomTab.iconSelect else bottomTab.iconUnselect))
.placeholder(com.gh.gamecenter.common.R.drawable.occupy)
.into(tabIv)
}
root.isChecked = bottomTab.default

View File

@ -330,7 +330,6 @@ class MainWrapperFragment : BaseBottomTabFragment<PieceBottomTabBinding>(), OnBa
if (index < mBottomTabBindingList.size && bottomTab.iconSelector == 0) {
ImageUtils.picasso
.load(Uri.parse(if (position == index) bottomTab.iconSelect else bottomTab.iconUnselect))
.placeholder(com.gh.gamecenter.common.R.drawable.occupy)
.into(mBottomTabBindingList[index].tabIv)
}
}

View File

@ -13,7 +13,6 @@
android:layout_marginTop="16dp"
android:layout_marginRight="16dp"
app:fadeDuration="500"
app:placeholderImage="@drawable/occupy"
app:placeholderImageScaleType="fitXY"
app:roundedCornerRadius="8dp" />

View File

@ -37,8 +37,8 @@
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:placeholderImage="@drawable/occupy"
app:placeholderImageScaleType="fitXY"
app:placeholderImage="@drawable/occupy"
app:roundedCornerRadius="5dp"
app:viewAspectRatio="2"
tools:layout_width="match_parent"

View File

@ -5,6 +5,7 @@ import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.drawable.Animatable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Environment
import android.text.TextUtils
@ -36,7 +37,6 @@ import com.facebook.imagepipeline.request.Postprocessor
import com.gh.gamecenter.common.HaloApp
import com.gh.gamecenter.common.R
import com.gh.gamecenter.common.callback.BiCallback
import com.gh.gamecenter.common.constant.RouteConsts
import com.gh.gamecenter.core.GHThreadFactory
import com.gh.gamecenter.core.provider.IConfigProvider
import com.gh.gamecenter.core.runOnUiThread
@ -49,18 +49,24 @@ import com.squareup.picasso.Picasso
import io.reactivex.Single
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.schedulers.Schedulers
import thumbhash.ThumbHashDrawable
import java.io.*
import java.util.concurrent.Executors
object ImageUtils {
private const val TAG = "ImageUtils"
private const val THUMBHASH_PREFIX = "thumbhash"
private const val THUMBHASH_REGEX = "(?<=thumbhash_)([^.]+)"
private const val PIC_MAX_FILE_SIZE: Long = 10 * 1024 * 1024
private val TINY_ANIMATED_ICON_SIZE by lazy { 30F.dip2px() }
private val LARGE_ANIMATED_ICON_SIZE by lazy { 80F.dip2px() }
private val STANDARD_ANIMATED_ICON_SIZE by lazy { 60F.dip2px() }
private val thumbHashRegex by lazy { Regex(THUMBHASH_REGEX) }
val TAG_TARGET_WIDTH by lazy { R.dimen.width_placeholder }
val TAG_IS_GAME_ICON by lazy { R.dimen.game_icon_placeholder }
@ -213,6 +219,7 @@ object ImageUtils {
) {
if (simpleDraweeView == null) return
val context = simpleDraweeView.context ?: return
simpleDraweeView.hierarchy = GenericDraweeHierarchyBuilder(resources)
.setFadeDuration(500)
.setPressedStateOverlay(
@ -327,6 +334,12 @@ object ImageUtils {
var lowResUrl = ""
var highResUrl = ""
var thumbHash = if (view?.getTag(R.id.disable_thumbhash) == null) {
getThumbHashIfValid(url)
} else {
""
}
// 找同一图片地址已加载过的图片作为低质量预览图(字符串操作耗时不短,所以这里放到了独立的子线程来处理)
// TODO 根据实际请求大小(w_width)来避免小图用大图作为低质量图片
for (cachedImageUrl in imageUrlCacheSet) {
@ -346,13 +359,13 @@ object ImageUtils {
highResUrl = getTunedImageUrl(view, url, width ?: 0, loadAsAnimatedImage, processor)
runOnUiThread {
loadImage(view, highResUrl, lowResUrl, processor, disableMemoryCache, controllerListener)
loadImage(view, thumbHash, highResUrl, lowResUrl, processor, disableMemoryCache, controllerListener)
}
} else {
runOnUiThread {
view ?: return@runOnUiThread
highResUrl = getTunedImageUrl(view, url, width ?: view.width, true, processor)
loadImage(view, highResUrl, lowResUrl, processor, disableMemoryCache, controllerListener)
loadImage(view, thumbHash, highResUrl, lowResUrl, processor, disableMemoryCache, controllerListener)
}
}
view?.tag = url
@ -432,14 +445,15 @@ object ImageUtils {
}
private fun loadImage(
view: SimpleDraweeView?,
view: SimpleDraweeView,
thumbHash: String?,
highResUrl: String,
lowResUrl: String,
processor: Postprocessor?,
shouldNotSaveMemoryCache: Boolean,
controllerListener: BaseControllerListener<ImageInfo>? = null,
) {
val lifecycleObserver = view?.getTag(R.id.lifecycle_observer) as? LifecycleObserver
val lifecycleObserver = view.getTag(R.id.lifecycle_observer) as? LifecycleObserver
val lifecycle = if (lifecycleObserver != null) {
try {
view.findFragment()?.viewLifecycleOwner?.lifecycle
@ -447,11 +461,25 @@ object ImageUtils {
null
}
} else null
val fallbackPlaceholder = view.getTag(R.id.fallback_placeholder)
val fallbackPlaceholderScaleType = view.getTag(R.id.fallback_placeholder_scale_type) as? ScalingUtils.ScaleType
lifecycleObserver?.let {
lifecycle?.removeObserver(it)
view.setTag(R.id.lifecycle_observer, null)
}
if (!thumbHash.isNullOrEmpty()) {
view.hierarchy?.setPlaceholderImage(ThumbHashDrawable(thumbHash, view.width, view.height))
} else if (fallbackPlaceholder != null) {
if (fallbackPlaceholder is Int) {
view.hierarchy?.setPlaceholderImage(fallbackPlaceholder, fallbackPlaceholderScaleType)
} else if (fallbackPlaceholder is Drawable) {
view.hierarchy?.setPlaceholderImage(fallbackPlaceholder)
}
}
val listener = getFinalControllerListener(view, controllerListener, lifecycle)
val imageRequest = ImageRequestBuilder.newBuilderWithSource(Uri.parse(highResUrl))
.apply {
@ -467,7 +495,7 @@ object ImageUtils {
.apply {
if (lowResUrl.isNotEmpty()
&& lowResUrl != highResUrl
&& highResUrl != view?.getTag(R.string.highResImageTag)
&& highResUrl != view.getTag(R.string.highResImageTag)
) {
lowResImageRequest =
ImageRequestBuilder.newBuilderWithSource(Uri.parse(lowResUrl))
@ -477,9 +505,9 @@ object ImageUtils {
autoPlayAnimations = true
}
.build()
view?.controller = controller
view.controller = controller
view?.setTag(R.string.highResImageTag, highResUrl)
view.setTag(R.string.highResImageTag, highResUrl)
}
private fun getFinalControllerListener(
@ -803,4 +831,31 @@ object ImageUtils {
}
}
private fun getThumbHashIfValid(url: String) : String {
return if (url.contains(THUMBHASH_PREFIX)) {
var hash = thumbHashRegex.find(url)?.groups?.get(1)?.value ?: ""
hash.replace("-", "+").replace("_", "/")
} else {
""
}
}
}
fun SimpleDraweeView.withFallbackPlaceholder(fitXy: Boolean) {
if (fitXy) {
setTag(R.id.fallback_placeholder, R.drawable.occupy)
setTag(R.id.fallback_placeholder_scale_type, ScalingUtils.ScaleType.FIT_XY)
} else {
setTag(R.id.fallback_placeholder, R.drawable.occupy2)
setTag(R.id.fallback_placeholder_scale_type, ScalingUtils.ScaleType.CENTER)
}
}
fun SimpleDraweeView.withFallbackPlaceholder(drawable: Drawable) {
setTag(R.id.fallback_placeholder, drawable)
}
fun SimpleDraweeView.disableThumbHash() {
setTag(R.id.disable_thumbhash, true)
}

View File

@ -0,0 +1,363 @@
package thumbhash
import android.graphics.Bitmap
import android.util.Base64
import com.lightgame.utils.Utils
import java.nio.ByteBuffer
import kotlin.system.measureTimeMillis
object ThumbHash {
fun getBitmapFromHash(hash: String, fullWidth: Int? = null, fullHeight: Int? = null): Bitmap {
var bitmap: Bitmap? = null
val elapsedTime = measureTimeMillis {
val image = thumbHashToRGBA(Base64.decode(hash, Base64.DEFAULT))
val bmp = Bitmap.createBitmap(image.width, image.height, Bitmap.Config.ARGB_8888)
bmp.copyPixelsFromBuffer(ByteBuffer.wrap(image.rgba))
bitmap = bmp
}
Utils.log("getBitmapFromHash fullWidth $fullWidth and fullHeight $fullHeight elapsed time: $elapsedTime ms")
if (fullHeight == 0 || fullWidth == 0) return bitmap!!
fullWidth ?: return bitmap!!
fullHeight ?: return bitmap!!
return Bitmap.createScaledBitmap(bitmap!!, fullWidth, fullHeight, true)
}
/**
* Encodes an RGBA image to a ThumbHash. RGB should not be premultiplied by A.
*
* @param w The width of the input image. Must be ≤100px.
* @param h The height of the input image. Must be ≤100px.
* @param rgba The pixels in the input image, row-by-row. Must have w*h*4 elements.
* @return The ThumbHash as a byte array.
*/
fun rgbaToThumbHash(w: Int, h: Int, rgba: ByteArray): ByteArray {
// Encoding an image larger than 100x100 is slow with no benefit
require(!(w > 100 || h > 100)) { w.toString() + "x" + h + " doesn't fit in 100x100" }
// Determine the average color
var avg_r = 0f
var avg_g = 0f
var avg_b = 0f
var avg_a = 0f
run {
var i = 0
var j = 0
while (i < w * h) {
val alpha = (rgba[j + 3].toInt() and 255) / 255.0f
avg_r += alpha / 255.0f * (rgba[j].toInt() and 255)
avg_g += alpha / 255.0f * (rgba[j + 1].toInt() and 255)
avg_b += alpha / 255.0f * (rgba[j + 2].toInt() and 255)
avg_a += alpha
i++
j += 4
}
}
if (avg_a > 0) {
avg_r /= avg_a
avg_g /= avg_a
avg_b /= avg_a
}
val hasAlpha = avg_a < w * h
val l_limit = if (hasAlpha) 5 else 7 // Use fewer luminance bits if there's alpha
val lx = Math.max(1, Math.round((l_limit * w).toFloat() / Math.max(w, h).toFloat()))
val ly = Math.max(1, Math.round((l_limit * h).toFloat() / Math.max(w, h).toFloat()))
val l = FloatArray(w * h) // luminance
val p = FloatArray(w * h) // yellow - blue
val q = FloatArray(w * h) // red - green
val a = FloatArray(w * h) // alpha
// Convert the image from RGBA to LPQA (composite atop the average color)
var i = 0
var j = 0
while (i < w * h) {
val alpha = (rgba[j + 3].toInt() and 255) / 255.0f
val r = avg_r * (1.0f - alpha) + alpha / 255.0f * (rgba[j].toInt() and 255)
val g = avg_g * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 1].toInt() and 255)
val b = avg_b * (1.0f - alpha) + alpha / 255.0f * (rgba[j + 2].toInt() and 255)
l[i] = (r + g + b) / 3.0f
p[i] = (r + g) / 2.0f - b
q[i] = r - g
a[i] = alpha
i++
j += 4
}
// Encode using the DCT into DC (constant) and normalized AC (varying) terms
val l_channel = Channel(Math.max(3, lx), Math.max(3, ly)).encode(w, h, l)
val p_channel = Channel(3, 3).encode(w, h, p)
val q_channel = Channel(3, 3).encode(w, h, q)
val a_channel = if (hasAlpha) Channel(5, 5).encode(w, h, a) else null
// Write the constants
val isLandscape = w > h
val header24 = (Math.round(63.0f * l_channel.dc)
or (Math.round(31.5f + 31.5f * p_channel.dc) shl 6)
or (Math.round(31.5f + 31.5f * q_channel.dc) shl 12)
or (Math.round(31.0f * l_channel.scale) shl 18)
or if (hasAlpha) 1 shl 23 else 0)
val header16 = ((if (isLandscape) ly else lx)
or (Math.round(63.0f * p_channel.scale) shl 3)
or (Math.round(63.0f * q_channel.scale) shl 9)
or if (isLandscape) 1 shl 15 else 0)
val ac_start = if (hasAlpha) 6 else 5
val ac_count = (l_channel.ac.size + p_channel.ac.size + q_channel.ac.size
+ if (hasAlpha) a_channel!!.ac.size else 0)
val hash = ByteArray(ac_start + (ac_count + 1) / 2)
hash[0] = header24.toByte()
hash[1] = (header24 shr 8).toByte()
hash[2] = (header24 shr 16).toByte()
hash[3] = header16.toByte()
hash[4] = (header16 shr 8).toByte()
if (hasAlpha) hash[5] = (Math.round(15.0f * a_channel!!.dc)
or (Math.round(15.0f * a_channel.scale) shl 4)).toByte()
// Write the varying factors
var ac_index = 0
ac_index = l_channel.writeTo(hash, ac_start, ac_index)
ac_index = p_channel.writeTo(hash, ac_start, ac_index)
ac_index = q_channel.writeTo(hash, ac_start, ac_index)
if (hasAlpha) a_channel!!.writeTo(hash, ac_start, ac_index)
return hash
}
/**
* Decodes a ThumbHash to an RGBA image. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The width, height, and pixels of the rendered placeholder image.
*/
fun thumbHashToRGBA(hash: ByteArray): Image {
// Read the constants
val header24 = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
val header16 = hash[3].toInt() and 255 or (hash[4].toInt() and 255 shl 8)
val l_dc = (header24 and 63).toFloat() / 63.0f
val p_dc = (header24 shr 6 and 63).toFloat() / 31.5f - 1.0f
val q_dc = (header24 shr 12 and 63).toFloat() / 31.5f - 1.0f
val l_scale = (header24 shr 18 and 31).toFloat() / 31.0f
val hasAlpha = header24 shr 23 != 0
val p_scale = (header16 shr 3 and 63).toFloat() / 63.0f
val q_scale = (header16 shr 9 and 63).toFloat() / 63.0f
val isLandscape = header16 shr 15 != 0
val lx = Math.max(3, if (isLandscape) if (hasAlpha) 5 else 7 else header16 and 7)
val ly = Math.max(3, if (isLandscape) header16 and 7 else if (hasAlpha) 5 else 7)
val a_dc = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
val a_scale = (hash[5].toInt() shr 4 and 15).toFloat() / 15.0f
// Read the varying factors (boost saturation by 1.25x to compensate for quantization)
val ac_start = if (hasAlpha) 6 else 5
var ac_index = 0
val l_channel = Channel(lx, ly)
val p_channel = Channel(3, 3)
val q_channel = Channel(3, 3)
var a_channel: Channel? = null
ac_index = l_channel.decode(hash, ac_start, ac_index, l_scale)
ac_index = p_channel.decode(hash, ac_start, ac_index, p_scale * 1.25f)
ac_index = q_channel.decode(hash, ac_start, ac_index, q_scale * 1.25f)
if (hasAlpha) {
a_channel = Channel(5, 5)
a_channel.decode(hash, ac_start, ac_index, a_scale)
}
val l_ac = l_channel.ac
val p_ac = p_channel.ac
val q_ac = q_channel.ac
val a_ac = if (hasAlpha) a_channel!!.ac else null
// Decode using the DCT into RGB
val ratio = thumbHashToApproximateAspectRatio(hash)
val w = Math.round(if (ratio > 1.0f) 32.0f else 32.0f * ratio)
val h = Math.round(if (ratio > 1.0f) 32.0f / ratio else 32.0f)
val rgba = ByteArray(w * h * 4)
val cx_stop = Math.max(lx, if (hasAlpha) 5 else 3)
val cy_stop = Math.max(ly, if (hasAlpha) 5 else 3)
val fx = FloatArray(cx_stop)
val fy = FloatArray(cy_stop)
var y = 0
var i = 0
while (y < h) {
var x = 0
while (x < w) {
var l = l_dc
var p = p_dc
var q = q_dc
var a = a_dc
// Precompute the coefficients
for (cx in 0 until cx_stop) fx[cx] = Math.cos(Math.PI / w * (x + 0.5f) * cx).toFloat()
for (cy in 0 until cy_stop) fy[cy] = Math.cos(Math.PI / h * (y + 0.5f) * cy).toFloat()
// Decode L
run {
var cy = 0
var j = 0
while (cy < ly) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx * ly < lx * (ly - cy)) {
l += l_ac[j] * fx[cx] * fy2
cx++
j++
}
cy++
}
}
// Decode P and Q
var cy = 0
var j = 0
while (cy < 3) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx < 3 - cy) {
val f = fx[cx] * fy2
p += p_ac[j] * f
q += q_ac[j] * f
cx++
j++
}
cy++
}
// Decode A
if (hasAlpha) {
var cy = 0
var j = 0
while (cy < 5) {
val fy2 = fy[cy] * 2.0f
var cx = if (cy > 0) 0 else 1
while (cx < 5 - cy) {
a += a_ac!![j] * fx[cx] * fy2
cx++
j++
}
cy++
}
}
// Convert to RGB
val b = l - 2.0f / 3.0f * p
val r = (3.0f * l - b + q) / 2.0f
val g = r - q
rgba[i] = Math.max(0, Math.round(255.0f * Math.min(1f, r))).toByte()
rgba[i + 1] = Math.max(0, Math.round(255.0f * Math.min(1f, g))).toByte()
rgba[i + 2] = Math.max(0, Math.round(255.0f * Math.min(1f, b))).toByte()
rgba[i + 3] = Math.max(0, Math.round(255.0f * Math.min(1f, a))).toByte()
x++
i += 4
}
y++
}
return Image(w, h, rgba)
}
/**
* Extracts the average color from a ThumbHash. RGB is not be premultiplied by A.
*
* @param hash The bytes of the ThumbHash.
* @return The RGBA values for the average color. Each value ranges from 0 to 1.
*/
fun thumbHashToAverageRGBA(hash: ByteArray): RGBA {
val header = hash[0].toInt() and 255 or (hash[1].toInt() and 255 shl 8) or (hash[2].toInt() and 255 shl 16)
val l = (header and 63).toFloat() / 63.0f
val p = (header shr 6 and 63).toFloat() / 31.5f - 1.0f
val q = (header shr 12 and 63).toFloat() / 31.5f - 1.0f
val hasAlpha = header shr 23 != 0
val a = if (hasAlpha) (hash[5].toInt() and 15).toFloat() / 15.0f else 1.0f
val b = l - 2.0f / 3.0f * p
val r = (3.0f * l - b + q) / 2.0f
val g = r - q
return RGBA(
Math.max(0f, Math.min(1f, r)),
Math.max(0f, Math.min(1f, g)),
Math.max(0f, Math.min(1f, b)),
a
)
}
/**
* Extracts the approximate aspect ratio of the original image.
*
* @param hash The bytes of the ThumbHash.
* @return The approximate aspect ratio (i.e. width / height).
*/
fun thumbHashToApproximateAspectRatio(hash: ByteArray): Float {
val header = hash[3]
val hasAlpha = hash[2].toInt() and 0x80 != 0
val isLandscape = hash[4].toInt() and 0x80 != 0
val lx = if (isLandscape) if (hasAlpha) 5 else 7 else header.toInt() and 7
val ly = if (isLandscape) header.toInt() and 7 else if (hasAlpha) 5 else 7
return lx.toFloat() / ly.toFloat()
}
data class Image(var width: Int, var height: Int, var rgba: ByteArray)
data class RGBA(var r: Float, var g: Float, var b: Float, var a: Float)
private class Channel internal constructor(var nx: Int, var ny: Int) {
var dc = 0f
var ac: FloatArray
var scale = 0f
init {
var n = 0
for (cy in 0 until ny) {
var cx = if (cy > 0) 0 else 1
while (cx * ny < nx * (ny - cy)) {
n++
cx++
}
}
ac = FloatArray(n)
}
fun encode(w: Int, h: Int, channel: FloatArray): Channel {
var n = 0
val fx = FloatArray(w)
for (cy in 0 until ny) {
var cx = 0
while (cx * ny < nx * (ny - cy)) {
var f = 0f
for (x in 0 until w) fx[x] = Math.cos(Math.PI / w * cx * (x + 0.5f)).toFloat()
for (y in 0 until h) {
val fy = Math.cos(Math.PI / h * cy * (y + 0.5f)).toFloat()
for (x in 0 until w) f += channel[x + y * w] * fx[x] * fy
}
f /= (w * h).toFloat()
if (cx > 0 || cy > 0) {
ac[n++] = f
scale = Math.max(scale, Math.abs(f))
} else {
dc = f
}
cx++
}
}
if (scale > 0) for (i in ac.indices) ac[i] = 0.5f + 0.5f / scale * ac[i]
return this
}
fun decode(hash: ByteArray, start: Int, index: Int, scale: Float): Int {
var index = index
for (i in ac.indices) {
val data = hash[start + (index shr 1)].toInt() shr (index and 1 shl 2)
ac[i] = ((data and 15).toFloat() / 7.5f - 1.0f) * scale
index++
}
return index
}
fun writeTo(hash: ByteArray, start: Int, index: Int): Int {
var index = index
for (v in ac) {
hash[start + (index shr 1)] =
(hash[start + (index shr 1)].toInt() or (Math.round(15.0f * v) shl (index and 1 shl 2))).toByte()
index++
}
return index
}
}
}

View File

@ -0,0 +1,76 @@
package thumbhash
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.core.graphics.withTranslation
class ThumbHashDrawable(
private val hash: String,
private val targetWidth: Int = MIN_SIZE, // Too large a resolution will affect performance
private val targetHeight: Int = MIN_SIZE, // Too large a resolution will affect performance
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var bitmap: Bitmap? = null
override fun getIntrinsicHeight(): Int {
return bitmap?.height ?: targetHeight
}
override fun getIntrinsicWidth(): Int {
return bitmap?.width ?: targetWidth
}
override fun onBoundsChange(bounds: Rect) {
updateThumbHash(bounds)
}
private fun calculateSize(boundSize: Int, targetSize: Int): Int {
return if (targetSize > MIN_SIZE) targetSize else boundSize
}
private fun updateThumbHash(bounds: Rect) {
val width = calculateSize(bounds.width(), targetWidth)
val height = calculateSize(bounds.height(), targetHeight)
val bitmap = this.bitmap
if (bitmap != null) {
if (width == bitmap.width && height == bitmap.height) {
return
}
}
this.bitmap = ThumbHash.getBitmapFromHash(hash, width, height)
invalidateSelf()
}
override fun draw(canvas: Canvas) {
val bitmap = this.bitmap ?: return
val rect = bounds
canvas.withTranslation(rect.left.toFloat(), rect.top.toFloat()) {
drawBitmap(bitmap, 0F, 0F, paint)
}
}
override fun setAlpha(alpha: Int) {
paint.alpha = alpha
invalidateSelf()
}
override fun setColorFilter(colorFilter: ColorFilter?) {
paint.colorFilter = colorFilter
invalidateSelf()
}
override fun getOpacity(): Int {
return PixelFormat.OPAQUE
}
companion object {
internal const val MIN_SIZE = 0
}
}

View File

@ -2,4 +2,7 @@
<resources>
<item name="root_container" type="id" />
<item name="lifecycle_observer" type="id" />
<item name="fallback_placeholder" type="id" />
<item name="disable_thumbhash" type="id" />
<item name="fallback_placeholder_scale_type" type="id" />
</resources>

View File

@ -168,10 +168,7 @@ class GameIconView : FrameLayout {
setTag(ImageUtils.TAG_IS_GAME_ICON, true)
mGameIconIv?.hierarchy?.setPlaceholderImage(
ContextCompat.getDrawable(context, com.gh.gamecenter.common.R.drawable.occupy),
ScalingUtils.ScaleType.FIT_XY
)
mGameIconIv?.withFallbackPlaceholder(fitXy = true)
mGameIconIv?.aspectRatio = 1.0F
mGameIconIv?.display(icon)

View File

@ -777,7 +777,6 @@ class ComposeBindPhoneActivity : ComposeBaseActivity() {
SimpleDraweeView(it).apply {
val builder = GenericDraweeHierarchyBuilder(it.resources)
hierarchy = builder.setFadeDuration(500)
.setPlaceholderImage(com.gh.gamecenter.common.R.drawable.occupy)
.setPlaceholderImageScaleType(ScalingUtils.ScaleType.FIT_XY)
.setRoundingParams(RoundingParams.asCircle().apply {
borderColor = com.gh.gamecenter.common.R.color.ui_divider.toColor(it)