mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-26 21:34:05 +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
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
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.MediaInfo
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
import com.google.android.gms.common.images.WebImage
|
import com.google.android.gms.common.images.WebImage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
class PlaybackSession(
|
class PlaybackSession(
|
||||||
@@ -202,6 +206,13 @@ class PlaybackSession(
|
|||||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
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
|
@JsonIgnore
|
||||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||||
val coverUri = getCoverUri(ctx)
|
val coverUri = getCoverUri(ctx)
|
||||||
@@ -217,16 +228,41 @@ class PlaybackSession(
|
|||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
.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(
|
.putString(
|
||||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||||
coverUri.toString()
|
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) {
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
val bitmap =
|
resolvedCoverBitmap =
|
||||||
if (Build.VERSION.SDK_INT < 28) {
|
if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
} else {
|
} else {
|
||||||
@@ -234,11 +270,18 @@ class PlaybackSession(
|
|||||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
ImageDecoder.decodeBitmap(source)
|
ImageDecoder.decodeBitmap(source)
|
||||||
}
|
}
|
||||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
onArtResolved()
|
||||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
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
|
@JsonIgnore
|
||||||
|
|||||||
+4
-26
@@ -6,10 +6,8 @@ import android.graphics.ImageDecoder
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
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.Player
|
||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
@@ -36,7 +34,9 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
|||||||
): Bitmap? {
|
): Bitmap? {
|
||||||
val albumArtUri = controller.metadata.description.iconUri
|
val albumArtUri = controller.metadata.description.iconUri
|
||||||
val albumBitmap = controller.metadata.description.iconBitmap
|
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
|
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||||
if (albumBitmap != null) {
|
if (albumBitmap != null) {
|
||||||
return albumBitmap
|
return albumBitmap
|
||||||
@@ -59,7 +59,7 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
|||||||
} else {
|
} else {
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
currentBitmap = albumArtUri?.let {
|
currentBitmap = albumArtUri?.let {
|
||||||
resolveUriAsBitmap(it)
|
resolveUriAsBitmap(playerNotificationService, it)
|
||||||
}
|
}
|
||||||
currentBitmap?.let { callback.onBitmap(it) }
|
currentBitmap?.let { callback.onBitmap(it) }
|
||||||
}
|
}
|
||||||
@@ -69,26 +69,4 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
|||||||
currentBitmap
|
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 com.google.android.exoplayer2.upstream.*
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
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
|
import kotlinx.coroutines.runBlocking
|
||||||
|
|
||||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||||
@@ -116,6 +121,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
var currentPlaybackSession: PlaybackSession? = null
|
var currentPlaybackSession: PlaybackSession? = null
|
||||||
private var initialPlaybackRate: Float? = null
|
private var initialPlaybackRate: Float? = null
|
||||||
|
|
||||||
|
private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||||
|
private var metadataArtJob: Job? = null
|
||||||
|
|
||||||
private var isAndroidAuto = false
|
private var isAndroidAuto = false
|
||||||
|
|
||||||
// The following are used for the shake detection
|
// The following are used for the shake detection
|
||||||
@@ -198,6 +206,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
castPlayer?.release()
|
castPlayer?.release()
|
||||||
mediaSession.release()
|
mediaSession.release()
|
||||||
mediaProgressSyncer.reset()
|
mediaProgressSyncer.reset()
|
||||||
|
metadataScope.cancel()
|
||||||
|
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
@@ -295,6 +304,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
transportControls = mediaController.transportControls
|
transportControls = mediaController.transportControls
|
||||||
|
|
||||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
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 =
|
val queueNavigator: TimelineQueueNavigator =
|
||||||
object : TimelineQueueNavigator(mediaSession) {
|
object : TimelineQueueNavigator(mediaSession) {
|
||||||
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||||
@@ -430,8 +445,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
isClosed = false
|
isClosed = false
|
||||||
|
|
||||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
|
||||||
mediaSession.setMetadata(metadata)
|
|
||||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||||
initialPlaybackRate = playbackRate
|
initialPlaybackRate = playbackRate
|
||||||
@@ -459,6 +472,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playbackSession
|
playbackSession
|
||||||
) // Save playback session to use when app is closed
|
) // 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}")
|
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||||
// Notify client
|
// Notify client
|
||||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||||
|
|||||||
Reference in New Issue
Block a user