mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.net.toUri
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
@@ -133,7 +132,7 @@ class Media3PlayerHandle(
|
|||||||
.setTitle(item.title)
|
.setTitle(item.title)
|
||||||
.setArtist(item.artist)
|
.setArtist(item.artist)
|
||||||
.setAlbumTitle(item.album)
|
.setAlbumTitle(item.album)
|
||||||
.setArtworkUri(sourceRepository.coverArt(item.coverArtId, true)?.url?.toUri())
|
.setArtworkUri(item.coverArtId?.let(CoverArtArtwork::uri))
|
||||||
.setIsBrowsable(false)
|
.setIsBrowsable(false)
|
||||||
.setIsPlayable(true)
|
.setIsPlayable(true)
|
||||||
.setExtras(
|
.setExtras(
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ import androidx.media3.exoplayer.ExoPlayer
|
|||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.session.MediaSession
|
import androidx.media3.session.MediaSession
|
||||||
import androidx.media3.session.MediaSessionService
|
import androidx.media3.session.MediaSessionService
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
import com.subtracks.MainActivity
|
import com.subtracks.MainActivity
|
||||||
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.koin.core.context.GlobalContext
|
||||||
|
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
private var session: MediaSession? = null
|
private var session: MediaSession? = null
|
||||||
|
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
val player =
|
val player =
|
||||||
@@ -45,6 +49,12 @@ class PlaybackService : MediaSessionService() {
|
|||||||
Intent(this, MainActivity::class.java),
|
Intent(this, MainActivity::class.java),
|
||||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||||
),
|
),
|
||||||
|
).setBitmapLoader(
|
||||||
|
CoverArtBitmapLoader(
|
||||||
|
this,
|
||||||
|
GlobalContext.get().get<SourceRepository>(),
|
||||||
|
SingletonImageLoader.get(this),
|
||||||
|
),
|
||||||
).build()
|
).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