EARHORNMUSIC UPnP · Android TV · Phones · Android Auto · GNU/Linux
Release 0.5.0

CoilBitmapLoader.kt

package dev.stave.playback

import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.BitmapLoader
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
import coil3.size.Size
import coil3.toBitmap
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

/**
 * BitmapLoader that uses Coil to fetch artwork at a defined target
 * resolution. Replacing the default DataSourceBitmapLoader produces
 * consistently large bitmaps for Auto's now-playing card and the
 * system media notification — the default decodes whatever resolution
 * the URI happens to return, which for local content://albumart can
 * be a 256-px thumbnail and for UPnP varies wildly by server.
 *
 * Target [TARGET_SIZE_PX] is 1024x1024 — the largest size Auto's
 * MediaPlaybackTemplate will display before downscaling, and a sane
 * upper bound for memory usage when caching.
 */
@UnstableApi
class CoilBitmapLoader(
    private val context: Context,
) : BitmapLoader {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
    private val loader: ImageLoader
        get() = SingletonImageLoader.get(context)

    override fun supportsMimeType(mimeType: String): Boolean {
        return mimeType.startsWith("image/")
    }

    override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> {
        val future = SettableFuture.create<Bitmap>()
        scope.launch {
            try {
                val bmp = BitmapFactory.decodeByteArray(data, 0, data.size)
                if (bmp != null) future.set(bmp)
                else future.setException(IllegalArgumentException("Failed to decode bitmap from ${data.size} bytes"))
            } catch (t: Throwable) {
                future.setException(t)
            }
        }
        return future
    }

    override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
        val future = SettableFuture.create<Bitmap>()
        scope.launch {
            try {
                val request = ImageRequest.Builder(context)
                    .data(uri)
                    .size(Size(TARGET_SIZE_PX, TARGET_SIZE_PX))
                    .allowHardware(false)
                    .build()
                when (val result = loader.execute(request)) {
                    is SuccessResult -> future.set(result.image.toBitmap())
                    else -> future.setException(IllegalStateException("Coil failed for $uri"))
                }
            } catch (t: Throwable) {
                future.setException(t)
            }
        }
        return future
    }

    companion object {
        const val TARGET_SIZE_PX = 1024
    }
}