Skip to content

Android: artwork thread writes stale MediaSession metadata, so notification/lock-screen title lags one track behind #1280

Description

@k-ljung

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

  1. Show playback notification metadata and enable play, pause, nextTrack, previousTrack, seekTo.
  2. Start playback of a queue with several tracks, using a remote artwork URL (loading must take non-trivial time).
  3. Press next (or previous), either in-app or from the notification.
  4. 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:

  1. 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.

  2. 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.

Activity

  1. added
    bugSomething isn't working as expected or produces unexpected errors
    on Sep 10, 2026
  2. added
    androidNative Android implementation, C++/Java/Kotlin bindings, or Android-specific issues
    on Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    androidNative Android implementation, C++/Java/Kotlin bindings, or Android-specific issuesbugSomething isn't working as expected or produces unexpected errors

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions