Summary
On Android with react-native-audio-api@0.13.3, the media notification and lock-screen controls display the previous track's title after switching tracks, even though JS passes the correct metadata to PlaybackNotificationManager.show() synchronously and immediately.
The cause is in PlaybackNotification.updateInternal(): the async artwork thread reads back metadata via MediaControllerCompat.getMetadata() and writes it to the session again. MediaControllerCompat is updated asynchronously over IPC, so shortly after setMetadata() it still returns the previous metadata. The artwork thread then republishes that stale metadata, overwriting the correct one.
Since Android 13+ renders both the notification shade media widget and the lock-screen controls from the MediaSession metadata, the previous track's title becomes visible again.
This is a separate defect from #1273 (which is about MEDIA_BUTTON intents not being handled), but it lives in the same code block — see "Relationship to #1273" below.
Environment
react-native-audio-api: 0.13.3
- React Native:
0.81.5
- Expo SDK:
54
- Android: reproduced on Android 15, real device
- New Architecture enabled
Reproduction observed in app
- Show playback notification metadata and enable
play, pause, nextTrack, previousTrack, seekTo.
- Start playback of a queue with several tracks, using a remote artwork URL (loading must take non-trivial time).
- Press next (or previous), either in-app or from the notification.
- Observe the title in the notification shade widget and on the lock screen.
Expected behavior
The notification and lock-screen title update to the newly loaded track.
Actual behavior
The title briefly shows the correct track, then reverts to the previous track's title and stays there until the next track change. The effect is consistently "one track behind".
We verified from the JS side that the correct value is sent every time. Logging the exact payload handed to PlaybackNotificationManager.show() at each track change:
playNext -> { nextIndex: 1, arrangementId: "1189d1b0-…" }
show() payload -> { title: "Can't stop the feeling", state: "paused" }
metadata loaded -> { id: "1189d1b0-…", title: "Can't stop the feeling" }
show() payload -> { title: "Can't stop the feeling", state: "playing" }
The title sent to native is correct and synchronous, while the native UI still renders the previous track. This rules out the JS layer.
Code-level analysis
In PlaybackNotification.kt, updateInternal() starts a background thread for artwork and, on completion, republishes metadata:
val currentMetadata = mediaSession?.controller?.metadata
val newBuilder = MediaMetadataCompat.Builder(currentMetadata ?: MediaMetadataCompat.Builder().build())
mediaSession?.setMetadata(newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build())
Two problems:
-
MediaControllerCompat.getMetadata() is not a reliable read-back of setMetadata(). The controller receives metadata through the session's IPC callback, so immediately after setMetadata() it can still return the previous value. Using it as the base means the previous track's METADATA_KEY_TITLE / ARTIST / DURATION get republished.
-
The interrupt of the previous artwork thread is not effective. updateInternal() calls artworkThread.interrupt(), but the thread is typically blocked in URL.openConnection() / BitmapFactory.decodeStream(). A blocking socket read is not interruptible, so the old thread keeps running and completes after the new track has already published its metadata.
Together these mean the artwork callback of track N can overwrite the metadata of track N+1.
This is timing-dependent, which likely explains why it does not appear with fast/local artwork or in a minimal repro — it needs artwork loading to overlap a track change.
Suggested fix
Keep an authoritative copy of the last published metadata instead of reading it back from the controller, and ignore artwork results belonging to a superseded request.
private var artworkThread: Thread? = null
+ // Authoritative copy of the last metadata we published; MediaControllerCompat.getMetadata()
+ // lags behind setMetadata() over IPC and would resurrect the previous track.
+ private var lastMetadata: MediaMetadataCompat? = null
+ private var artworkRequestId: Long = 0L
+
private fun initializeIfNeeded() {
@@
if (artworkUri != null) {
+ val requestId = ++artworkRequestId
artworkThread =
Thread {
try {
val bitmap = loadArtwork(artworkUri, localArtwork)
if (bitmap != null) {
- artwork = bitmap
val context = reactContext.get()
context?.runOnUiQueueThread {
+ // A newer track started while this artwork was loading; discard the result.
+ if (requestId != artworkRequestId) return@runOnUiQueueThread
+
+ artwork = bitmap
notificationBuilder?.setLargeIcon(bitmap)
- val currentMetadata = mediaSession?.controller?.metadata
- val newBuilder = MediaMetadataCompat.Builder(currentMetadata ?: MediaMetadataCompat.Builder().build())
- mediaSession?.setMetadata(newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build())
+ val base = lastMetadata
+ val newBuilder =
+ if (base != null) MediaMetadataCompat.Builder(base) else MediaMetadataCompat.Builder()
+ val updated = newBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap).build()
+ lastMetadata = updated
+ mediaSession?.setMetadata(updated)
// Trigger update
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
notificationManager.notify(notificationId, buildNotification())
@@
if (artwork != null) {
md.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, artwork)
}
- mediaSession?.setMetadata(md.build())
+ val builtMetadata = md.build()
+ lastMetadata = builtMetadata
+ mediaSession?.setMetadata(builtMetadata)
lastMetadata is also cleared in hide() alongside artwork.
Applied as a local patch, this fully resolves the lagging title on our device. It requires a native rebuild to take effect.
Relationship to #1273
This is a distinct bug from the missing MEDIA_BUTTON handling in #1273, but it shares a root location with the ConcurrentModificationException reported there. That stack trace points into the same artwork callback:
androidx.core.app.NotificationCompat$Builder.build(NotificationCompat.java:2528)
com.swmansion.audioapi.system.notification.PlaybackNotification.buildNotification(PlaybackNotification.kt:515)
com.swmansion.audioapi.system.notification.PlaybackNotification.updateInternal$lambda$1$lambda$0(PlaybackNotification.kt:249)
updateInternal$lambda$1$lambda$0 is the runOnUiQueueThread block inside the artwork Thread. So the artwork callback mutates shared state (notificationBuilder, mediaSession, artwork) from outside the normal update flow, which produces both the crash and the stale metadata. Guarding stale results and not reading back from the controller addresses the metadata half; adding synchronization around show() / hide() / buildNotification() addresses the crash half.
Note on a minimal repro
To set expectations honestly: I do not have bandwidth to build a public MRE in the near term, so please do not wait on one from me.
This report is based on a real app reproduction. I am filing it anyway because the cause does not depend on my observation being reproduced — it is a documented property of the Android API: MediaControllerCompat.getMetadata() is populated through the session's IPC callback and is not a valid read-back of a preceding setMetadata() call. The current code uses it as the base for the metadata it republishes, which is unsafe regardless of whether the resulting lag is visible on a given device or timing profile.
Anyone wanting to reproduce it deliberately would need artwork loading slow enough to overlap a track change, for example a deliberately throttled remote artwork URL combined with rapid next/previous presses.
Summary
On Android with
react-native-audio-api@0.13.3, the media notification and lock-screen controls display the previous track's title after switching tracks, even though JS passes the correct metadata toPlaybackNotificationManager.show()synchronously and immediately.The cause is in
PlaybackNotification.updateInternal(): the async artwork thread reads back metadata viaMediaControllerCompat.getMetadata()and writes it to the session again.MediaControllerCompatis updated asynchronously over IPC, so shortly aftersetMetadata()it still returns the previous metadata. The artwork thread then republishes that stale metadata, overwriting the correct one.Since Android 13+ renders both the notification shade media widget and the lock-screen controls from the
MediaSessionmetadata, the previous track's title becomes visible again.This is a separate defect from #1273 (which is about
MEDIA_BUTTONintents not being handled), but it lives in the same code block — see "Relationship to #1273" below.Environment
react-native-audio-api:0.13.30.81.554Reproduction observed in app
play,pause,nextTrack,previousTrack,seekTo.Expected behavior
The notification and lock-screen title update to the newly loaded track.
Actual behavior
The title briefly shows the correct track, then reverts to the previous track's title and stays there until the next track change. The effect is consistently "one track behind".
We verified from the JS side that the correct value is sent every time. Logging the exact payload handed to
PlaybackNotificationManager.show()at each track change:The title sent to native is correct and synchronous, while the native UI still renders the previous track. This rules out the JS layer.
Code-level analysis
In
PlaybackNotification.kt,updateInternal()starts a background thread for artwork and, on completion, republishes metadata:Two problems:
MediaControllerCompat.getMetadata()is not a reliable read-back ofsetMetadata(). The controller receives metadata through the session's IPC callback, so immediately aftersetMetadata()it can still return the previous value. Using it as the base means the previous track'sMETADATA_KEY_TITLE/ARTIST/DURATIONget republished.The interrupt of the previous artwork thread is not effective.
updateInternal()callsartworkThread.interrupt(), but the thread is typically blocked inURL.openConnection()/BitmapFactory.decodeStream(). A blocking socket read is not interruptible, so the old thread keeps running and completes after the new track has already published its metadata.Together these mean the artwork callback of track N can overwrite the metadata of track N+1.
This is timing-dependent, which likely explains why it does not appear with fast/local artwork or in a minimal repro — it needs artwork loading to overlap a track change.
Suggested fix
Keep an authoritative copy of the last published metadata instead of reading it back from the controller, and ignore artwork results belonging to a superseded request.
lastMetadatais also cleared inhide()alongsideartwork.Applied as a local patch, this fully resolves the lagging title on our device. It requires a native rebuild to take effect.
Relationship to #1273
This is a distinct bug from the missing
MEDIA_BUTTONhandling in #1273, but it shares a root location with theConcurrentModificationExceptionreported there. That stack trace points into the same artwork callback:updateInternal$lambda$1$lambda$0is therunOnUiQueueThreadblock inside the artworkThread. So the artwork callback mutates shared state (notificationBuilder,mediaSession,artwork) from outside the normal update flow, which produces both the crash and the stale metadata. Guarding stale results and not reading back from the controller addresses the metadata half; adding synchronization aroundshow()/hide()/buildNotification()addresses the crash half.Note on a minimal repro
To set expectations honestly: I do not have bandwidth to build a public MRE in the near term, so please do not wait on one from me.
This report is based on a real app reproduction. I am filing it anyway because the cause does not depend on my observation being reproduced — it is a documented property of the Android API:
MediaControllerCompat.getMetadata()is populated through the session's IPC callback and is not a valid read-back of a precedingsetMetadata()call. The current code uses it as the base for the metadata it republishes, which is unsafe regardless of whether the resulting lag is visible on a given device or timing profile.Anyone wanting to reproduce it deliberately would need artwork loading slow enough to overlap a track change, for example a deliberately throttled remote artwork URL combined with rapid next/previous presses.