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:
parent
b30aba4bdf
commit
37697abfce
5 changed files with 232 additions and 0 deletions
42
app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt
vendored
Normal file
42
app/src/main/java/org/oxycblt/auxio/image/stack/cache/AppFiles.kt
vendored
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
38
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt
vendored
Normal file
38
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt
vendored
Normal 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()
|
||||||
|
}
|
68
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt
vendored
Normal file
68
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt
vendored
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
49
app/src/main/java/org/oxycblt/auxio/image/stack/cache/PerceptualHash.kt
vendored
Normal file
49
app/src/main/java/org/oxycblt/auxio/image/stack/cache/PerceptualHash.kt
vendored
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
35
app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt
vendored
Normal file
35
app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt
vendored
Normal 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
|
||||||
|
)
|
Loading…
Reference in a new issue