perf(audio): reuse media session worker and bound metadata retention

This commit is contained in:
zarzet committed 2026-10-04 01:40:55 +07:00
1 parent 9c0b22e2c8
commit a57b4504de
5 files changed
+234 -34

No files matched your search

+1
View File
@@ -73,4 +73,5 @@ android {
dependencies {
implementation("androidx.media:media:1.8.0")
implementation("androidx.core:core:1.19.1")
testImplementation("junit:junit:4.13.2")
}
@@ -42,7 +42,6 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -100,11 +99,12 @@ public class AudioService extends MediaBrowserServiceCompat {
| PlaybackStateCompat.ACTION_SET_SHUFFLE_MODE
| PlaybackStateCompat.ACTION_SET_CAPTIONING_ENABLED;
static AudioService instance;
static volatile AudioService instance;
private volatile boolean destroyed;
private static PendingIntent contentIntent;
private static ServiceListener listener;
private static List<MediaSessionCompat.QueueItem> queue = new ArrayList<>();
private static final Map<String, MediaMetadataCompat> mediaMetadataCache = new HashMap<>();
private static final PinnedMetadataCache<MediaMetadataCompat> mediaMetadataCache = new PinnedMetadataCache<>(256);
public static void init(ServiceListener listener) {
AudioService.listener = listener;
@@ -164,7 +164,10 @@ public class AudioService extends MediaBrowserServiceCompat {
}
}
MediaMetadataCompat mediaMetadata = builder.build();
mediaMetadataCache.put(mediaId, mediaMetadata);
synchronized (mediaMetadataCache) {
if (destroyed) throw new IllegalStateException("Audio service is no longer attached");
mediaMetadataCache.put(mediaId, mediaMetadata);
}
return mediaMetadata;
}
@@ -359,6 +362,10 @@ public class AudioService extends MediaBrowserServiceCompat {
@Override
public void onDestroy() {
synchronized (mediaMetadataCache) {
destroyed = true;
mediaMetadataCache.clear();
}
super.onDestroy();
if (listener != null) {
listener.onDestroy();
@@ -367,7 +374,6 @@ public class AudioService extends MediaBrowserServiceCompat {
mediaMetadata = null;
artBitmap = null;
queue.clear();
mediaMetadataCache.clear();
controls.clear();
artBitmapCache.evictAll();
compactActionIndices = null;
@@ -800,7 +806,11 @@ public class AudioService extends MediaBrowserServiceCompat {
* Updates queue.
* Gets called from background thread.
*/
synchronized void setQueue(List<MediaSessionCompat.QueueItem> queue) {
synchronized void setQueue(List<MediaSessionCompat.QueueItem> queue, Map<String, MediaMetadataCompat> metadata) {
synchronized (mediaMetadataCache) {
if (destroyed) throw new IllegalStateException("Audio service is no longer attached");
mediaMetadataCache.setQueue(metadata);
}
AudioService.queue = queue;
mediaSession.setQueue(queue);
}
@@ -822,6 +832,10 @@ public class AudioService extends MediaBrowserServiceCompat {
* - https://9to5google.com/2020/08/02/android-11-lockscreen-art/
*/
synchronized void setMetadata(MediaMetadataCompat mediaMetadata) {
synchronized (mediaMetadataCache) {
if (destroyed) throw new IllegalStateException("Audio service is no longer attached");
mediaMetadataCache.setCurrent(mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID), mediaMetadata);
}
String artCacheFilePath = mediaMetadata.getString("artCacheFile");
if (artCacheFilePath != null) {
// Load local files and network images, cached in files
@@ -838,6 +852,7 @@ public class AudioService extends MediaBrowserServiceCompat {
artBitmap = null;
}
}
if (destroyed) throw new IllegalStateException("Audio service is no longer attached");
this.mediaMetadata = mediaMetadata;
mediaSession.setMetadata(mediaMetadata);
handler.removeCallbacksAndMessages(null);
@@ -907,19 +922,19 @@ public class AudioService extends MediaBrowserServiceCompat {
@Override
public void onAddQueueItem(MediaDescriptionCompat description) {
if (listener == null) return;
listener.onAddQueueItem(getMediaMetadata(description.getMediaId()));
listener.onAddQueueItem(description);
}
@Override
public void onAddQueueItem(MediaDescriptionCompat description, int index) {
if (listener == null) return;
listener.onAddQueueItemAt(getMediaMetadata(description.getMediaId()), index);
listener.onAddQueueItemAt(description, index);
}
@Override
public void onRemoveQueueItem(MediaDescriptionCompat description) {
if (listener == null) return;
listener.onRemoveQueueItem(getMediaMetadata(description.getMediaId()));
listener.onRemoveQueueItem(description);
}
@Override
@@ -1141,7 +1156,7 @@ public class AudioService extends MediaBrowserServiceCompat {
public void onPlayMediaItem(final MediaDescriptionCompat description) {
if (listener == null) return;
listener.onPlayMediaItem(getMediaMetadata(description.getMediaId()));
listener.onPlayMediaItem(description);
}
}
@@ -1172,9 +1187,9 @@ public class AudioService extends MediaBrowserServiceCompat {
void onSetRepeatMode(int repeatMode);
void onSetShuffleMode(int shuffleMode);
void onCustomAction(String action, Bundle extras);
void onAddQueueItem(MediaMetadataCompat metadata);
void onAddQueueItemAt(MediaMetadataCompat metadata, int index);
void onRemoveQueueItem(MediaMetadataCompat metadata);
void onAddQueueItem(MediaDescriptionCompat description);
void onAddQueueItemAt(MediaDescriptionCompat description, int index);
void onRemoveQueueItem(MediaDescriptionCompat description);
void onRemoveQueueItemAt(int index);
void onSetPlaybackSpeed(float speed);
void onSetCaptioningEnabled(boolean enabled);
@@ -1185,7 +1200,7 @@ public class AudioService extends MediaBrowserServiceCompat {
// NON-STANDARD METHODS
//
void onPlayMediaItem(MediaMetadataCompat metadata);
void onPlayMediaItem(MediaDescriptionCompat description);
void onTaskRemoved();
void onClose();
void onDestroy();
@@ -34,6 +34,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import io.flutter.embedding.android.FlutterActivity;
@@ -131,6 +132,7 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
private static final String CHANNEL_CLIENT = "com.ryanheise.audio_service.client.methods";
private static final String CHANNEL_HANDLER = "com.ryanheise.audio_service.handler.methods";
private static final String DESCRIPTION_METADATA = "com.ryanheise.audio_service.metadata";
private static final Set<ClientInterface> clientInterfaces = new HashSet<>();
private static ClientInterface mainClientInterface;
@@ -520,6 +522,8 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
public MethodChannel channel;
private AudioTrack silenceAudioTrack;
private final Handler handler = new Handler(Looper.getMainLooper());
private final ExecutorService mediaWorker = Executors.newSingleThreadExecutor();
private volatile boolean destroyed;
private List<MethodInvocation> methodInvocationQueue = new LinkedList<MethodInvocation>();
public AudioHandlerInterface(BinaryMessenger messenger) {
@@ -702,8 +706,8 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
}
@Override
public void onPlayMediaItem(MediaMetadataCompat metadata) {
invokeMethod("playMediaItem", mapOf("mediaItem", mediaMetadata2raw(metadata)));
public void onPlayMediaItem(MediaDescriptionCompat description) {
invokeMediaItemMethod("playMediaItem", description, null);
}
@Override
@@ -712,20 +716,48 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
}
@Override
public void onAddQueueItem(MediaMetadataCompat metadata) {
invokeMethod("addQueueItem", mapOf("mediaItem", mediaMetadata2raw(metadata)));
public void onAddQueueItem(MediaDescriptionCompat description) {
invokeMediaItemMethod("addQueueItem", description, null);
}
@Override
public void onAddQueueItemAt(MediaMetadataCompat metadata, int index) {
invokeMethod("insertQueueItem", mapOf(
"mediaItem", mediaMetadata2raw(metadata),
"index", index));
public void onAddQueueItemAt(MediaDescriptionCompat description, int index) {
invokeMediaItemMethod("insertQueueItem", description, index);
}
@Override
public void onRemoveQueueItem(MediaMetadataCompat metadata) {
invokeMethod("removeQueueItem", mapOf("mediaItem", mediaMetadata2raw(metadata)));
public void onRemoveQueueItem(MediaDescriptionCompat description) {
invokeMediaItemMethod("removeQueueItem", description, null);
}
private void invokeMediaItemMethod(String method, MediaDescriptionCompat description, Integer index) {
MediaMetadataCompat metadata = AudioService.getMediaMetadata(description.getMediaId());
if (metadata == null && description.getExtras() != null) {
metadata = description.getExtras().getParcelable(DESCRIPTION_METADATA);
}
if (metadata != null) {
sendMediaItemMethod(method, mediaMetadata2raw(metadata), index);
return;
}
// A browser can retain a description after its metadata leaves the
// recent cache. Resolve it through the same Dart API as onLoadItem.
invokeMethod("getMediaItem", mapOf("mediaId", description.getMediaId()), new Result() {
@Override
public void success(Object value) {
if (destroyed || !(value instanceof Map)) return;
Object item = ((Map<?, ?>)value).get("mediaItem");
if (item instanceof Map) sendMediaItemMethod(method, (Map<?, ?>)item, index);
}
@Override
public void error(String code, String message, Object details) { }
@Override
public void notImplemented() { }
});
}
private void sendMediaItemMethod(String method, Map<?, ?> item, Integer index) {
invokeMethod(method, index == null ? mapOf("mediaItem", item)
: mapOf("mediaItem", item, "index", index));
}
@Override
@@ -835,11 +867,15 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
Map<?, ?> args = (Map<?, ?>)call.arguments;
switch (call.method) {
case "setMediaItem": {
Executors.newSingleThreadExecutor().execute(() -> {
final AudioService service = AudioService.instance;
mediaWorker.execute(() -> {
try {
if (destroyed || service == null || AudioService.instance != service) {
throw new IllegalStateException("Audio service is no longer attached");
}
Map<?, ?> rawMediaItem = (Map<?, ?>)args.get("mediaItem");
MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem);
AudioService.instance.setMetadata(mediaMetadata);
MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem, service);
service.setMetadata(mediaMetadata);
handler.post(() -> result.success(null));
} catch (Exception e) {
handler.post(() -> {
@@ -850,11 +886,16 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
break;
}
case "setQueue": {
Executors.newSingleThreadExecutor().execute(() -> {
final AudioService service = AudioService.instance;
mediaWorker.execute(() -> {
try {
if (destroyed || service == null || AudioService.instance != service) {
throw new IllegalStateException("Audio service is no longer attached");
}
@SuppressWarnings("unchecked") List<Map<?, ?>> rawQueue = (List<Map<?, ?>>) args.get("queue");
List<MediaSessionCompat.QueueItem> queue = raw2queue(rawQueue);
AudioService.instance.setQueue(queue);
Map<String, MediaMetadataCompat> metadata = new HashMap<>();
List<MediaSessionCompat.QueueItem> queue = raw2queue(rawQueue, metadata, service);
service.setQueue(queue, metadata);
handler.post(() -> result.success(null));
} catch (Exception e) {
handler.post(() -> {
@@ -1016,6 +1057,9 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
}
private void destroy() {
destroyed = true;
channel.setMethodCallHandler(null);
mediaWorker.shutdown();
if (silenceAudioTrack != null)
silenceAudioTrack.release();
}
@@ -1129,8 +1173,12 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
}
private static MediaMetadataCompat createMediaMetadata(Map<?, ?> rawMediaItem) {
return createMediaMetadata(rawMediaItem, AudioService.instance);
}
private static MediaMetadataCompat createMediaMetadata(Map<?, ?> rawMediaItem, AudioService service) {
//noinspection unchecked
return AudioService.instance.createMediaMetadata(
return service.createMediaMetadata(
(String)rawMediaItem.get("id"),
(String)rawMediaItem.get("title"),
(String)rawMediaItem.get("album"),
@@ -1178,16 +1226,32 @@ public class AudioServicePlugin implements FlutterPlugin, ActivityAware {
private static MediaBrowserCompat.MediaItem rawToMediaItem(Map<?, ?> rawMediaItem) {
MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem);
final MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map<?, ?>)rawMediaItem.get("extras"));
MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map<?, ?>)rawMediaItem.get("extras"));
// Browser descriptions can outlive the bounded server cache. Keep their
// metadata with the returned item instead of retaining every browsed ID.
Bundle extras = new Bundle();
if (description.getExtras() != null) extras.putAll(description.getExtras());
extras.putParcelable(DESCRIPTION_METADATA, mediaMetadata);
description = new MediaDescriptionCompat.Builder()
.setMediaId(description.getMediaId())
.setTitle(description.getTitle())
.setSubtitle(description.getSubtitle())
.setDescription(description.getDescription())
.setIconBitmap(description.getIconBitmap())
.setIconUri(description.getIconUri())
.setMediaUri(description.getMediaUri())
.setExtras(extras)
.build();
final Boolean playable = (Boolean)rawMediaItem.get("playable");
return new MediaBrowserCompat.MediaItem(description, playable ? MediaBrowserCompat.MediaItem.FLAG_PLAYABLE : MediaBrowserCompat.MediaItem.FLAG_BROWSABLE);
}
private static List<MediaSessionCompat.QueueItem> raw2queue(List<Map<?, ?>> rawQueue) {
private static List<MediaSessionCompat.QueueItem> raw2queue(List<Map<?, ?>> rawQueue, Map<String, MediaMetadataCompat> metadata, AudioService service) {
List<MediaSessionCompat.QueueItem> queue = new ArrayList<>();
int i = 0;
for (Map<?, ?> rawMediaItem : rawQueue) {
MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem);
MediaMetadataCompat mediaMetadata = createMediaMetadata(rawMediaItem, service);
metadata.put(mediaMetadata.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID), mediaMetadata);
MediaDescriptionCompat description = addExtrasToMediaDescription(mediaMetadata.getDescription(), (Map<?, ?>)rawMediaItem.get("extras"));
queue.add(new MediaSessionCompat.QueueItem(description, i));
i++;
@@ -0,0 +1,52 @@
package com.ryanheise.audioservice;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/** Keeps current/queue items while bounding metadata retained from older browsers. */
final class PinnedMetadataCache<T> {
private final int recentLimit;
private final Map<String, T> recent = new LinkedHashMap<>(16, 0.75f, true);
private Map<String, T> queue = new HashMap<>();
private String currentId;
private T current;
PinnedMetadataCache(int recentLimit) {
if (recentLimit < 1) throw new IllegalArgumentException("recentLimit must be positive");
this.recentLimit = recentLimit;
}
synchronized void put(String id, T metadata) {
recent.put(id, metadata);
while (recent.size() > recentLimit) {
recent.remove(recent.keySet().iterator().next());
}
}
synchronized T get(String id) {
if (id != null && id.equals(currentId)) return current;
T metadata = queue.get(id);
return metadata != null ? metadata : recent.get(id);
}
synchronized void setCurrent(String id, T metadata) {
currentId = id;
current = metadata;
}
synchronized void setQueue(Map<String, T> metadata) {
queue = new HashMap<>(metadata);
}
synchronized void clear() {
recent.clear();
queue.clear();
currentId = null;
current = null;
}
synchronized int recentSize() {
return recent.size();
}
}
@@ -0,0 +1,68 @@
package com.ryanheise.audioservice;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
public class PinnedMetadataCacheTest {
@Test
public void queueLargerThanRecentLimitAndCurrentStayAvailableAfterChurn() {
PinnedMetadataCache<String> cache = new PinnedMetadataCache<>(2);
Map<String, String> queue = new HashMap<>();
for (int i = 0; i < 10; i++) queue.put("queue-" + i, "metadata-" + i);
cache.setQueue(queue);
queue.clear();
cache.setCurrent("playing", "current");
for (int i = 0; i < 1000; i++) cache.put("browser-" + i, "old");
for (int i = 0; i < 10; i++) assertEquals("metadata-" + i, cache.get("queue-" + i));
assertEquals("current", cache.get("playing"));
assertEquals(2, cache.recentSize());
cache.setQueue(new HashMap<>());
assertNull(cache.get("queue-0"));
cache.clear();
assertNull(cache.get("playing"));
assertEquals(0, cache.recentSize());
}
@Test
public void recentBrowserReadsRefreshEvictionOrder() {
PinnedMetadataCache<String> cache = new PinnedMetadataCache<>(2);
cache.put("first", "a");
cache.put("second", "b");
assertEquals("a", cache.get("first"));
cache.put("third", "c");
assertNull(cache.get("second"));
assertEquals("a", cache.get("first"));
}
@Test
public void concurrentBrowserAndWorkerWritesRemainBounded() throws Exception {
PinnedMetadataCache<String> cache = new PinnedMetadataCache<>(16);
cache.setCurrent("playing", "current");
ExecutorService workers = Executors.newFixedThreadPool(4);
List<Future<?>> results = new ArrayList<>();
try {
for (int i = 0; i < 1000; i++) {
final String id = "item-" + i;
results.add(workers.submit(() -> {
cache.put(id, id);
cache.get(id);
}));
}
} finally {
workers.shutdown();
}
org.junit.Assert.assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
for (Future<?> result : results) result.get();
assertEquals(16, cache.recentSize());
assertEquals("current", cache.get("playing"));
}
}