diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0e59afa533..137446211e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -142,7 +142,6 @@ dependencies { implementation(libs.bundles.markwon) // Image utility - implementation(libs.blurhash) implementation(libs.bundles.coil) // Crash Reporting diff --git a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt index c82a7b3f97..598589697d 100644 --- a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt +++ b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt @@ -7,7 +7,6 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.await -import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -57,12 +56,6 @@ class JellyfinApplication : Application() { launch { socketListener.updateSession() } } - override fun onLowMemory() { - super.onLowMemory() - - BlurHash.clearCache() - } - override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt b/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt index d6082d9b58..326c365a36 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt @@ -12,11 +12,11 @@ import androidx.lifecycle.lifecycleScope import coil.ImageLoader import coil.request.ImageRequest import coil.transform.CircleCropTransformation -import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.androidtv.R +import org.jellyfin.androidtv.util.BlurHashDecoder import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.math.round @@ -65,7 +65,7 @@ class AsyncImageView @JvmOverloads constructor( // Only show blurhash if an image is going to be loaded from the network if (url != null && blurHash != null) withContext(Dispatchers.IO) { - val blurHashBitmap = BlurHash.decode( + val blurHashBitmap = BlurHashDecoder.decode( blurHash, if (aspectRatio > 1) round(blurHashResolution * aspectRatio).toInt() else blurHashResolution, if (aspectRatio >= 1) blurHashResolution else round(blurHashResolution / aspectRatio).toInt(), diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/composable/AsyncImage.kt b/app/src/main/java/org/jellyfin/androidtv/ui/composable/AsyncImage.kt index f176cd3781..2291743853 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/composable/AsyncImage.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/composable/AsyncImage.kt @@ -13,8 +13,8 @@ import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.IntSize import androidx.compose.ui.viewinterop.AndroidView -import com.vanniktech.blurhash.BlurHash import org.jellyfin.androidtv.ui.AsyncImageView +import org.jellyfin.androidtv.util.BlurHashDecoder private data class AsyncImageState( val url: String?, @@ -64,7 +64,7 @@ fun blurHashPainter( size: IntSize, punch: Float = 1f, ): Painter = remember(blurHash, size, punch) { - val bitmap = BlurHash.decode( + val bitmap = BlurHashDecoder.decode( blurHash = blurHash, width = size.width, height = size.height, diff --git a/app/src/main/java/org/jellyfin/androidtv/util/BlurHashDecoder.kt b/app/src/main/java/org/jellyfin/androidtv/util/BlurHashDecoder.kt new file mode 100644 index 0000000000..7cf0157d8b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/util/BlurHashDecoder.kt @@ -0,0 +1,133 @@ +package org.jellyfin.androidtv.util + +import android.graphics.Bitmap +import android.graphics.Color +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +@Suppress("MagicNumber", "NestedBlockDepth") +object BlurHashDecoder { + private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + + /** + * Decode a blur hash into a new bitmap. + */ + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { + if (blurHash == null || blurHash.length < 6) return null + + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + val totalComp = numCompX * numCompY + if (blurHash.length != 4 + 2 * totalComp) return null + + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = FloatArray(totalComp * 3) + var colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc, colors) + + for (i in 1 until totalComp) { + val from = 4 + i * 2 + colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch, colors, i * 3) + } + + return composeBitmap(width, height, numCompX, numCompY, colors) + } + + private fun decode83(str: String, from: Int, to: Int): Int { + var result = 0 + + for (i in from until to) { + val index = CHARS.indexOf(str[i]) + if (index != -1) result = result * 83 + index + } + + return result + } + + private fun decodeDc(colorEnc: Int, outArray: FloatArray) { + val r = (colorEnc shr 16) and 0xFF + val g = (colorEnc shr 8) and 0xFF + val b = colorEnc and 0xFF + + outArray[0] = srgbToLinear(r) + outArray[1] = srgbToLinear(g) + outArray[2] = srgbToLinear(b) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + + return when { + v <= 0.04045f -> (v / 12.92f) + else -> ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float, outArray: FloatArray, outIndex: Int) { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + + outArray[outIndex] = signedPow2((r - 9) / 9.0f) * maxAc + outArray[outIndex + 1] = signedPow2((g - 9) / 9.0f) * maxAc + outArray[outIndex + 2] = signedPow2((b - 9) / 9.0f) * maxAc + } + + private fun signedPow2(value: Float) = (value * value).withSign(value) + + private fun composeBitmap(width: Int, height: Int, numCompX: Int, numCompY: Int, colors: FloatArray): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val cosinesX = createCosines(width, numCompX) + val cosinesY = when { + width == height && numCompX == numCompY -> cosinesX + else -> createCosines(height, numCompY) + } + + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + + for (j in 0 until numCompY) { + val cosY = cosinesY[y * numCompY + j] + + for (i in 0 until numCompX) { + val cosX = cosinesX[x * numCompX + i] + val basis = cosX * cosY + val colorIndex = (j * numCompX + i) * 3 + r += colors[colorIndex] * basis + g += colors[colorIndex + 1] * basis + b += colors[colorIndex + 2] * basis + } + } + + imageArray[x + width * y] = Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun createCosines(size: Int, numComp: Int) = FloatArray(size * numComp) { index -> + val x = index / numComp + val i = index % numComp + + cos(PI * x * i / size).toFloat() + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + + return when { + v <= 0.0031308f -> (v * 12.92f * 255f + 0.5f).toInt() + else -> ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 515b045cf3..6a9c96961a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,6 @@ androidx-tv = "1.0.0-rc01" androidx-tvprovider = "1.1.0-alpha01" androidx-window = "1.3.0" androidx-work = "2.9.0" -blurhash = "0.3.0" coil = "2.6.0" detekt = "1.23.6" jellyfin-androidx-media = "1.3.1+2" @@ -98,7 +97,6 @@ markwon-core = { module = "io.noties.markwon:core", version.ref = "markwon" } markwon-html = { module = "io.noties.markwon:html", version.ref = "markwon" } # Image utility -blurhash = { module = "com.vanniktech:blurhash", version.ref = "blurhash" } coil-base = { module = "io.coil-kt:coil-base", version.ref = "coil" } coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" }