mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
load notification artwork through Coil by cover-art identity
This commit is contained in:
@@ -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")))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user