mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-05 03:18:45 +02:00
Render a higher resolution cover in the Android notification background (#1917)
* 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. * Remove the size override in Glide The override was not needed. It wasn't in the original code, so I'll remove it for consistency. * Update notification icon to reuse cover bitmap if already loaded on session --------- Co-authored-by: advplyr <advplyr@protonmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+4
-26
@@ -6,10 +6,8 @@ import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
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.*
|
||||
@@ -36,7 +34,9 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
val albumBitmap = controller.metadata.description.iconBitmap
|
||||
?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
|
||||
|
||||
// Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming)
|
||||
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||
if (albumBitmap != null) {
|
||||
return albumBitmap
|
||||
@@ -59,7 +59,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 +69,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,36 @@
|
||||
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)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user