music: introduce new image loader cache

This will be used with the new SAF-loaded music files to show covers.
This commit is contained in:
Alexander Capehart 2024-11-27 17:48:16 -07:00
parent b30aba4bdf
commit 37697abfce
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
5 changed files with 232 additions and 0 deletions

View file

@ -0,0 +1,42 @@
package org.oxycblt.auxio.image.stack.cache
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.io.InputStream
import javax.inject.Inject
interface AppFiles {
suspend fun read(file: String): InputStream?
suspend fun write(file: String, inputStream: InputStream): Boolean
}
class AppFilesImpl @Inject constructor(
@ApplicationContext private val context: Context
) : AppFiles {
override suspend fun read(file: String): InputStream? =
withContext(context = Dispatchers.IO) {
try {
context.openFileInput(file)
} catch (e: IOException) {
null
}
}
override suspend fun write(file: String, inputStream: InputStream) =
withContext(context = Dispatchers.IO) {
try {
context.openFileOutput(file, Context.MODE_PRIVATE).use { fileOutputStream ->
inputStream.copyTo(fileOutputStream)
}
true
} catch (e: IOException) {
false
} finally {
inputStream.close()
}
}
}

View file

@ -0,0 +1,38 @@
package org.oxycblt.auxio.image.stack.cache
import android.content.Context
import androidx.media3.datasource.cache.Cache
import androidx.room.Room
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.music.stack.explore.cache.TagCache
import org.oxycblt.auxio.music.stack.explore.cache.TagCacheImpl
import org.oxycblt.auxio.music.stack.explore.cache.TagDatabase
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
interface StackModule {
@Binds fun appFiles(impl: AppFilesImpl): AppFiles
@Binds fun cache(impl: CoverCacheImpl): Cache
@Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache
}
@Module
@InstallIn(SingletonComponent::class)
class StoredCoversDatabaseModule {
@Singleton
@Provides
fun database(@ApplicationContext context: Context) =
Room.databaseBuilder(context.applicationContext, StoredCoversDatabase::class.java, "stored_covers.db")
.fallbackToDestructiveMigration()
.build()
}

View file

@ -0,0 +1,68 @@
package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.Song
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Inject
interface CoverCache {
suspend fun read(song: Song): InputStream?
suspend fun write(song: Song, inputStream: InputStream): Boolean
}
class CoverCacheImpl @Inject constructor(
private val storedCoversDao: StoredCoversDao,
private val appFiles: AppFiles,
private val perceptualHash: PerceptualHash
) : CoverCache {
override suspend fun read(song: Song): InputStream? {
val perceptualHash = storedCoversDao.getCoverFile(song.uid, song.lastModified)
?: return null
return appFiles.read(fileName(perceptualHash))
}
override suspend fun write(song: Song, inputStream: InputStream): Boolean = withContext(Dispatchers.IO) {
val bitmap = BitmapFactory.decodeStream(inputStream)
val perceptualHash = perceptualHash.hash(bitmap)
// Compress bitmap down to webp into another inputstream
val compressedStream = ByteArrayOutputStream().use { outputStream ->
bitmap.compress(COVER_CACHE_FORMAT, 80, outputStream)
ByteArrayInputStream(outputStream.toByteArray())
}
val writeSuccess = appFiles.write(fileName(perceptualHash), compressedStream)
if (writeSuccess) {
storedCoversDao.setCoverFile(
StoredCover(
uid = song.uid,
lastModified = song.lastModified,
perceptualHash = perceptualHash
)
)
}
writeSuccess
}
private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png"
private companion object {
@Suppress("DEPRECATION")
val COVER_CACHE_FORMAT = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
}
}

View file

@ -0,0 +1,49 @@
package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
interface PerceptualHash {
fun hash(bitmap: Bitmap): String
}
class PerceptualHashImpl : PerceptualHash {
override fun hash(bitmap: Bitmap): String {
val hashSize = 16
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(bitmap, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}
}

View file

@ -0,0 +1,35 @@
package org.oxycblt.auxio.image.stack.cache
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import org.oxycblt.auxio.music.Music
@Database(entities = [StoredCover::class], version = 50, exportSchema = false)
abstract class StoredCoversDatabase : RoomDatabase() {
abstract fun storedCoversDao(): StoredCoversDao
}
@Dao
interface StoredCoversDao {
@Query("SELECT perceptualHash FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
fun getCoverFile(uid: Music.UID, lastModified: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun setCoverFile(storedCover: StoredCover)
}
@Entity
@TypeConverters(Music.UID.TypeConverters::class)
data class StoredCover(
@PrimaryKey
val uid: Music.UID,
val lastModified: Long,
val perceptualHash: String
)