From 58810a860b119941253b79707b0607e1836c1ab5 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 22:24:20 -0400 Subject: [PATCH] fix(android): notification art uses album cover, not embedded stream tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MediaController notification / lock-screen background pulled artwork from the stream's embedded ID3/FLAC tags (artworkData) because the MediaItem never set artworkUri — a different source than the in-app album cover (/api/albums/{id}/cover). For tracks whose embedded tag art differs from the server album cover, the two surfaces disagreed. - PlayerController.toMediaItem: set artworkUri to TrackRef.coverUrl. MediaMetadata.populate() overwrites artworkUri+artworkData as a pair, so the MediaItem URI clears the embedded bytes ExoPlayer extracts from the stream — the album cover now wins on both surfaces. - PlayerFactory.buildBitmapLoader: OkHttp-backed CacheBitmapLoader so the authed placeholder cover URL resolves (the default DefaultHttpDataSource loader can't rewrite placeholder.invalid or attach the auth cookie). - MinstrelPlayerService: attach it via MediaSession.setBitmapLoader. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../minstrel/player/MinstrelPlayerService.kt | 1 + .../minstrel/player/PlayerController.kt | 13 ++++++++++++ .../minstrel/player/PlayerFactory.kt | 21 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt index 7458a730..59741113 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/MinstrelPlayerService.kt @@ -77,6 +77,7 @@ class MinstrelPlayerService : MediaSessionService() { val session = MediaSession.Builder(this, player) .setSessionActivity(buildNowPlayingPendingIntent()) .setCallback(callback) + .setBitmapLoader(playerFactory.buildBitmapLoader()) .setMediaButtonPreferences(ImmutableList.of(buildLikeButton(isLiked = false))) .build() mediaSession = session diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt index 0305255b..e0f49216 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerController.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.os.SystemClock +import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.Player @@ -671,6 +672,18 @@ class PlayerController @Inject constructor( // paused under UPnP (it never probes a duration in that state). if (durationSec > 0) setDurationMs(durationSec.toLong() * MS_PER_SECOND) if (source != null) setExtras(sourceExtras(source)) + // Point the notification / lock-screen art at the SAME album + // cover the in-app surfaces use (TrackRef.coverUrl -> + // /api/albums/{id}/cover). Without this, Media3 falls back to + // whatever art is embedded in the stream's tags, which can be a + // different image than the server's album cover. Setting + // artworkUri here is load-bearing: MediaMetadata.populate() + // overwrites artworkUri + artworkData as a pair, so the + // MediaItem's URI clears any embedded artworkData ExoPlayer + // extracts from the stream -- the cover endpoint wins on both + // surfaces. The session's OkHttp-backed BitmapLoader (see + // PlayerFactory) is what makes this authed placeholder URL load. + if (coverUrl.isNotEmpty()) setArtworkUri(coverUrl.toUri()) } .build() // Server's stream_url is a relative path (/api/tracks/{id}/stream); diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 91176bb6..f3af0185 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -4,7 +4,9 @@ import android.content.Context import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player +import androidx.media3.common.util.BitmapLoader import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor @@ -12,6 +14,7 @@ import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.CacheBitmapLoader import com.fabledsword.minstrel.cache.audiocache.CacheConfig import com.fabledsword.minstrel.player.output.ActiveUpnpHolder import dagger.hilt.android.qualifiers.ApplicationContext @@ -113,6 +116,24 @@ class PlayerFactory @Inject constructor( .build() } + /** + * BitmapLoader for the MediaSession's notification / lock-screen art. + * Backed by the shared [okHttpClient] so it inherits the same + * BaseUrlInterceptor placeholder rewrite + auth cookie that Coil uses + * for in-app covers — without it the default DefaultHttpDataSource + * loader can't resolve `http://placeholder.invalid/...` and would 401 + * on the cover endpoint. Wrapped in CacheBitmapLoader so a cover the + * notification already fetched isn't re-loaded on every metadata + * refresh. Lets the album-cover artworkUri set in + * [PlayerController.toMediaItem] actually render on the media card. + */ + fun buildBitmapLoader(): BitmapLoader = + CacheBitmapLoader( + DataSourceBitmapLoader.Builder(context) + .setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient)) + .build(), + ) + private fun emitDrop(routeName: String) { dropEventsInternal.tryEmit(routeName) } -- 2.52.0