From 34710dbddcaa9ab5cbfe43b0808a51c815e08868 Mon Sep 17 00:00:00 2001 From: photown Date: Fri, 3 Jul 2026 16:32:47 -0400 Subject: [PATCH] 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. --- .../app/data/PlaybackSession.kt | 57 ++++++++++++++++--- .../app/player/AbMediaDescriptionAdapter.kt | 27 +-------- .../app/player/CoverImageLoader.kt | 37 ++++++++++++ .../app/player/PlayerNotificationService.kt | 24 +++++++- 4 files changed, 110 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt index 2a4b8b30..7ba51499 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -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 diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt index 8a19db5e..aabcb795 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt @@ -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() - } - } - } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt new file mode 100644 index 00000000..09647d63 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt index 815cd07c..86d4bdc3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -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)