Render a higher resolution bitmap in the notification background art

Previously only METADATA_KEY_ALBUM_ART_URI/ART_URI were set, leaving notification/lock-screen/Android Auto consumers to resolve the cover art themselves. Likely due to internal scaling, a lower quality bitmap was loaded.

This commit adds a shared resolveUriAsBitmap() helper (extracted from AbMediaDescriptionAdapter.kt) and uses it to:
  - Resolve and cache a bitmap on PlaybackSession once per
  session (synchronously for local covers, asynchronously for server covers), then set it on METADATA_KEY_ALBUM_ART/ART once available, falling back to the URI keys until it resolves.
  - Reuse the same helper in AbMediaDescriptionAdapter so the notification icon is resolved consistently.

  PlayerNotificationService tracks the resolution Job and cancels/replaces it on each new playback session, invalidating the media session metadata once the bitmap is ready.
This commit is contained in:
photown
2026-07-05 23:32:28 -04:00
parent 185cba16eb
commit 34710dbddc
4 changed files with 110 additions and 35 deletions
@@ -1,6 +1,7 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
@@ -20,6 +21,9 @@ import com.google.android.exoplayer2.MediaMetadata
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaQueueItem
import com.google.android.gms.common.images.WebImage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
@@ -202,6 +206,13 @@ class PlaybackSession(
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
}
/** Bitmap for the cover art, once resolved */
@JsonIgnore
private var resolvedCoverBitmap: Bitmap? = null
/**
* Builds the current session metadata, including the cover art bitmap if it has already been resolved.
*/
@JsonIgnore
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
@@ -217,16 +228,41 @@ class PlaybackSession(
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putString(
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
coverUri.toString()
)
// Local covers get bitmap
if (resolvedCoverBitmap != null) {
metadataBuilder
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap)
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap)
} else {
metadataBuilder
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
}
return metadataBuilder.build()
}
/**
* Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers
* are fetched asynchronously) and calls `onArtResolved` once it's available
*
* Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously.
*/
@JsonIgnore
fun resolveCoverBitmapAsync(
ctx: Context,
coroutineScope: CoroutineScope,
onArtResolved: () -> Unit
): Job? {
val coverUri = getCoverUri(ctx)
// Local covers get bitmap synchronously, no async fetch needed
if (localLibraryItem?.coverContentUrl != null) {
val bitmap =
resolvedCoverBitmap =
if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
@@ -234,11 +270,18 @@ class PlaybackSession(
ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
onArtResolved()
return null
}
return metadataBuilder.build()
// Server-side cover: resolve the art bitmap async
return coroutineScope.launch {
val bitmap = resolveUriAsBitmap(ctx, coverUri)
bitmap?.let {
resolvedCoverBitmap = it
onArtResolved()
}
}
}
@JsonIgnore
@@ -7,9 +7,6 @@ import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.*
@@ -59,7 +56,7 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
resolveUriAsBitmap(playerNotificationService, it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
@@ -69,26 +66,4 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
currentBitmap
}
}
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
try {
Glide.with(playerNotificationService)
.asBitmap()
.load(uri)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit()
.get()
} catch (e: Exception) {
e.printStackTrace()
Glide.with(playerNotificationService)
.asBitmap()
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit()
.get()
}
}
}
}
@@ -0,0 +1,37 @@
package com.audiobookshelf.app.player
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.util.Log
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "CoverImageLoader"
/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */
suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
try {
Glide.with(context)
.asBitmap()
.load(uri)
.override(512, 512)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit()
.get()
} catch (e: Exception) {
Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e)
Glide.with(context)
.asBitmap()
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit()
.get()
}
}
}
@@ -54,6 +54,11 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import java.util.*
import kotlin.concurrent.schedule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.runBlocking
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
@@ -116,6 +121,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
var currentPlaybackSession: PlaybackSession? = null
private var initialPlaybackRate: Float? = null
private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var metadataArtJob: Job? = null
private var isAndroidAuto = false
// The following are used for the shake detection
@@ -198,6 +206,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
castPlayer?.release()
mediaSession.release()
mediaProgressSyncer.reset()
metadataScope.cancel()
super.onDestroy()
}
@@ -295,6 +304,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
transportControls = mediaController.transportControls
mediaSessionConnector = MediaSessionConnector(mediaSession)
// Without this, the connector's default metadata provider rebuilds metadata from the
// player's own state on media item transitions/timeline changes, dropping the cover art
// bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately.
mediaSessionConnector.setMediaMetadataProvider { _ ->
currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build()
}
val queueNavigator: TimelineQueueNavigator =
object : TimelineQueueNavigator(mediaSession) {
override fun getSupportedQueueNavigatorActions(player: Player): Long {
@@ -430,8 +445,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
isClosed = false
val metadata = playbackSession.getMediaMetadataCompat(ctx)
mediaSession.setMetadata(metadata)
val mediaItems = playbackSession.getMediaItems(ctx)
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
initialPlaybackRate = playbackRate
@@ -459,6 +472,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
playbackSession
) // Save playback session to use when app is closed
metadataArtJob?.cancel()
metadataArtJob =
playbackSession.resolveCoverBitmapAsync(ctx, metadataScope) {
mediaSessionConnector.invalidateMediaSessionMetadata()
}
mediaSessionConnector.invalidateMediaSessionMetadata()
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
// Notify client
clientEventEmitter?.onPlaybackSession(playbackSession)