load notification artwork through Coil by cover-art identity

This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent d11bd45fe7
commit 4095d038a9
4 changed files with 134 additions and 2 deletions
@@ -0,0 +1,98 @@
package com.subtracks.playback
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.annotation.OptIn
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.BitmapLoader
import androidx.media3.common.util.UnstableApi
import coil3.ImageLoader
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import coil3.request.allowHardware
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 com.subtracks.data.repo.SourceRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
private const val ARTWORK_SIZE = 512
object CoverArtArtwork {
const val SCHEME = "subtracks-cover"
fun uri(coverArtId: String): Uri =
Uri
.Builder()
.scheme(SCHEME)
.authority("cover")
.appendPath(coverArtId)
.build()
fun coverArtId(uri: Uri): String? = if (uri.scheme == SCHEME) uri.lastPathSegment?.takeIf { it.isNotEmpty() } else null
}
@OptIn(UnstableApi::class)
class CoverArtBitmapLoader(
private val context: Context,
private val sourceRepository: SourceRepository,
private val imageLoader: ImageLoader,
) : BitmapLoader {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun supportsMimeType(mimeType: String): Boolean = mimeType.startsWith("image/")
override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
val coverArtId =
CoverArtArtwork.coverArtId(uri)
?: return Futures.immediateFailedFuture(IllegalArgumentException("Unsupported artwork URI: $uri"))
val ref =
sourceRepository.coverArt(coverArtId, thumbnail = true)
?: return Futures.immediateFailedFuture(IllegalStateException("No active source for artwork"))
val future = SettableFuture.create<Bitmap>()
val request =
ImageRequest
.Builder(context)
.data(ref.url)
.memoryCacheKey(ref.cacheKey)
.diskCacheKey(ref.cacheKey)
.size(ARTWORK_SIZE)
.allowHardware(false)
.build()
scope.launch {
try {
val bitmap = (imageLoader.execute(request) as? SuccessResult)?.image?.toBitmap()
if (bitmap == null) {
future.setException(IllegalStateException("No bitmap for ${ref.cacheKey}"))
} else {
future.set(bitmap)
}
} catch (e: Exception) {
future.setException(e)
}
}
return future
}
override fun loadBitmapFromMetadata(metadata: MediaMetadata): ListenableFuture<Bitmap> {
metadata.artworkData?.let { return decodeBitmap(it) }
metadata.artworkUri?.let { return loadBitmap(it) }
return Futures.immediateFailedFuture(IllegalArgumentException("Artwork has no data or URI"))
}
override fun decodeBitmap(data: ByteArray): ListenableFuture<Bitmap> =
try {
BitmapFactory
.decodeByteArray(data, 0, data.size)
?.let { Futures.immediateFuture(it) }
?: Futures.immediateFailedFuture(IllegalArgumentException("Could not decode artwork"))
} catch (e: Exception) {
Futures.immediateFailedFuture(e)
}
}
@@ -2,7 +2,6 @@ package com.subtracks.playback
import android.os.Bundle
import android.util.Log
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
@@ -133,7 +132,7 @@ class Media3PlayerHandle(
.setTitle(item.title)
.setArtist(item.artist)
.setAlbumTitle(item.album)
.setArtworkUri(sourceRepository.coverArt(item.coverArtId, true)?.url?.toUri())
.setArtworkUri(item.coverArtId?.let(CoverArtArtwork::uri))
.setIsBrowsable(false)
.setIsPlayable(true)
.setExtras(
@@ -13,12 +13,16 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import coil3.SingletonImageLoader
import com.subtracks.MainActivity
import com.subtracks.data.repo.SourceRepository
import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext
class PlaybackService : MediaSessionService() {
private var session: MediaSession? = null
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
val player =
@@ -45,6 +49,12 @@ class PlaybackService : MediaSessionService() {
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
),
).setBitmapLoader(
CoverArtBitmapLoader(
this,
GlobalContext.get().get<SourceRepository>(),
SingletonImageLoader.get(this),
),
).build()
}
@@ -0,0 +1,25 @@
package com.subtracks.playback
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CoverArtArtworkTest {
@Test
fun artworkUrisRoundTripThroughTheCoverArtId() {
val coverArtId = "al-123/with space"
val uri = CoverArtArtwork.uri(coverArtId)
assertEquals(CoverArtArtwork.SCHEME, uri.scheme)
assertEquals(coverArtId, CoverArtArtwork.coverArtId(uri))
}
@Test
fun nonCoverArtUrisAreRejected() {
assertNull(CoverArtArtwork.coverArtId(Uri.parse("https://example.test/art")))
}
}