From 4095d038a95131876421b813f998f5bb098da5da Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:11:15 +0000 Subject: [PATCH] load notification artwork through Coil by cover-art identity --- .../playback/CoverArtBitmapLoader.kt | 98 +++++++++++++++++++ .../subtracks/playback/Media3PlayerHandle.kt | 3 +- .../com/subtracks/playback/PlaybackService.kt | 10 ++ .../subtracks/playback/CoverArtArtworkTest.kt | 25 +++++ 4 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/playback/CoverArtBitmapLoader.kt create mode 100644 app/src/test/kotlin/com/subtracks/playback/CoverArtArtworkTest.kt diff --git a/app/src/main/kotlin/com/subtracks/playback/CoverArtBitmapLoader.kt b/app/src/main/kotlin/com/subtracks/playback/CoverArtBitmapLoader.kt new file mode 100644 index 00000000..50353495 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/playback/CoverArtBitmapLoader.kt @@ -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 { + 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() + 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 { + 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 = + try { + BitmapFactory + .decodeByteArray(data, 0, data.size) + ?.let { Futures.immediateFuture(it) } + ?: Futures.immediateFailedFuture(IllegalArgumentException("Could not decode artwork")) + } catch (e: Exception) { + Futures.immediateFailedFuture(e) + } +} diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 6cdf7f5a..cdb995e8 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -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( diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index b8e96af1..bc565196 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -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(), + SingletonImageLoader.get(this), + ), ).build() } diff --git a/app/src/test/kotlin/com/subtracks/playback/CoverArtArtworkTest.kt b/app/src/test/kotlin/com/subtracks/playback/CoverArtArtworkTest.kt new file mode 100644 index 00000000..185f57a7 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/playback/CoverArtArtworkTest.kt @@ -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"))) + } +}