NowPlayingWatcher follows the head unit's media sessions (listening only, never connecting to a player); NowPlaying picks what plays and cleans the text. Tiles shows source, title, artist, cover and progress under the battery; Halloween carves title and artist into the hill. Settings: "Show what's playing", on by default. Nothing is logged or sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
216 lines
8.4 KiB
Java
216 lines
8.4 KiB
Java
package com.evsuite.abrp;
|
|
|
|
import android.content.Context;
|
|
import android.graphics.Bitmap;
|
|
import android.graphics.BitmapFactory;
|
|
import android.media.MediaMetadata;
|
|
import android.media.session.MediaController;
|
|
import android.media.session.MediaSessionManager;
|
|
import android.media.session.PlaybackState;
|
|
import android.net.Uri;
|
|
import android.os.Handler;
|
|
import android.os.Looper;
|
|
|
|
import java.io.InputStream;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.concurrent.ExecutorService;
|
|
import java.util.concurrent.Executors;
|
|
|
|
/**
|
|
* Watches the media sessions the head unit publishes (Bluetooth's, CarPlay's, the radio's) for the
|
|
* Overview's "now playing", the way the Logs tab's music-sources check reads them: through
|
|
* {@code MediaSessionManager}, which needs MEDIA_CONTENT_CONTROL (the car build has it as the
|
|
* system user). It only listens: no media browser service is connected to (that stopped the music
|
|
* in 2.42.0) and no command is sent. Each session's state and song are kept as the session reports
|
|
* changes, so reading {@link #current()} costs no call into another app. Runs only while
|
|
* MainActivity says so, on the main thread. Without the permission, or on any failure, there is
|
|
* simply nothing playing. Nothing it sees is logged.
|
|
*/
|
|
final class NowPlayingWatcher {
|
|
|
|
private static final int COVER_PX = 160;
|
|
|
|
private final Context app;
|
|
private final Handler main = new Handler(Looper.getMainLooper());
|
|
private final List<Watched> watched = new ArrayList<>();
|
|
private final ExecutorService coverLoader = Executors.newSingleThreadExecutor(r -> {
|
|
Thread t = new Thread(r, "now-playing-cover");
|
|
t.setDaemon(true);
|
|
return t;
|
|
});
|
|
private MediaSessionManager sessions;
|
|
private boolean running;
|
|
private Bitmap sessionCover;
|
|
/** The file cover asked for last and what came of it, from the loader thread. */
|
|
private volatile String coverUri;
|
|
private volatile Bitmap fileCover;
|
|
|
|
private final MediaSessionManager.OnActiveSessionsChangedListener changed = this::watch;
|
|
|
|
NowPlayingWatcher(Context context) {
|
|
app = context.getApplicationContext();
|
|
}
|
|
|
|
void setRunning(boolean run) {
|
|
if (run == running) return;
|
|
running = run;
|
|
if (!run) {
|
|
try {
|
|
if (sessions != null) sessions.removeOnActiveSessionsChangedListener(changed);
|
|
} catch (RuntimeException ignored) {
|
|
// Gone with the listener either way.
|
|
}
|
|
watch(null);
|
|
return;
|
|
}
|
|
try {
|
|
sessions = (MediaSessionManager) app.getSystemService(Context.MEDIA_SESSION_SERVICE);
|
|
if (sessions == null) return;
|
|
sessions.addOnActiveSessionsChangedListener(changed, null, main);
|
|
watch(sessions.getActiveSessions(null));
|
|
} catch (RuntimeException e) {
|
|
// No permission (the stable build) or no session service: nothing plays.
|
|
android.util.Log.w("NowPlaying", "Media sessions not readable: " + e.getClass().getSimpleName());
|
|
}
|
|
}
|
|
|
|
boolean isRunning() {
|
|
return running;
|
|
}
|
|
|
|
/** What plays now, or null. */
|
|
NowPlaying.Track current() {
|
|
List<NowPlaying.Session> seen = new ArrayList<>(watched.size());
|
|
for (Watched w : watched) seen.add(w.seen);
|
|
return NowPlaying.pick(seen);
|
|
}
|
|
|
|
/** The current track's cover, or null: the picture the session carries, else its file, once loaded. */
|
|
Bitmap cover(NowPlaying.Track track) {
|
|
if (track == null) return null;
|
|
for (Watched w : watched) {
|
|
if (w.seen == track.session && w.cover != null) return w.cover;
|
|
}
|
|
String uri = track.session.artUri;
|
|
if (uri == null) return null;
|
|
if (!uri.equals(coverUri)) {
|
|
coverUri = uri;
|
|
fileCover = null;
|
|
coverLoader.execute(() -> {
|
|
Bitmap b = load(uri);
|
|
if (uri.equals(coverUri)) fileCover = b;
|
|
});
|
|
}
|
|
return fileCover;
|
|
}
|
|
|
|
private void watch(List<MediaController> controllers) {
|
|
for (Watched w : watched) w.release();
|
|
watched.clear();
|
|
if (controllers == null || !running) return;
|
|
for (MediaController c : controllers) {
|
|
try {
|
|
watched.add(new Watched(c));
|
|
} catch (RuntimeException ignored) {
|
|
// A session that went away while being read.
|
|
}
|
|
}
|
|
}
|
|
|
|
/** One session: its last state and song, kept up to date by its callback. */
|
|
private final class Watched extends MediaController.Callback {
|
|
final MediaController controller;
|
|
final NowPlaying.Session seen = new NowPlaying.Session();
|
|
Bitmap cover;
|
|
|
|
Watched(MediaController c) {
|
|
controller = c;
|
|
seen.packageName = c.getPackageName();
|
|
onPlaybackStateChanged(c.getPlaybackState());
|
|
onMetadataChanged(c.getMetadata());
|
|
c.registerCallback(this, main);
|
|
}
|
|
|
|
void release() {
|
|
try {
|
|
controller.unregisterCallback(this);
|
|
} catch (RuntimeException ignored) {
|
|
// Already gone.
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void onPlaybackStateChanged(PlaybackState state) {
|
|
seen.state = state == null ? 0 : state.getState();
|
|
seen.positionMs = state == null ? -1 : state.getPosition();
|
|
seen.positionUpdatedAt = state == null ? 0 : state.getLastPositionUpdateTime();
|
|
seen.speed = state == null ? 1f : state.getPlaybackSpeed();
|
|
}
|
|
|
|
@Override
|
|
public void onMetadataChanged(MediaMetadata m) {
|
|
seen.title = text(m, MediaMetadata.METADATA_KEY_TITLE);
|
|
if (seen.title == null) seen.title = text(m, MediaMetadata.METADATA_KEY_DISPLAY_TITLE);
|
|
seen.artist = text(m, MediaMetadata.METADATA_KEY_ARTIST);
|
|
if (seen.artist == null) seen.artist = text(m, MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE);
|
|
seen.durationMs = m == null ? 0 : Math.max(0, m.getLong(MediaMetadata.METADATA_KEY_DURATION));
|
|
cover = picture(m);
|
|
seen.artUri = text(m, MediaMetadata.METADATA_KEY_ALBUM_ART_URI);
|
|
if (seen.artUri == null) seen.artUri = text(m, MediaMetadata.METADATA_KEY_ART_URI);
|
|
}
|
|
|
|
@Override
|
|
public void onSessionDestroyed() {
|
|
seen.state = 0;
|
|
}
|
|
}
|
|
|
|
private static String text(MediaMetadata m, String key) {
|
|
if (m == null) return null;
|
|
try {
|
|
CharSequence s = m.getText(key);
|
|
return s == null ? null : s.toString();
|
|
} catch (RuntimeException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static Bitmap picture(MediaMetadata m) {
|
|
if (m == null) return null;
|
|
for (String key : new String[] { MediaMetadata.METADATA_KEY_ALBUM_ART, MediaMetadata.METADATA_KEY_ART,
|
|
MediaMetadata.METADATA_KEY_DISPLAY_ICON }) {
|
|
try {
|
|
Bitmap b = m.getBitmap(key);
|
|
if (b != null && b.getWidth() > 1 && b.getHeight() > 1) return b;
|
|
} catch (RuntimeException ignored) {
|
|
// Try the next.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** A cover file, made small; null when it cannot be read. Off the main thread. */
|
|
private Bitmap load(String uri) {
|
|
try {
|
|
Uri u = uri.startsWith("/") ? Uri.fromFile(new java.io.File(uri)) : Uri.parse(uri);
|
|
String scheme = u.getScheme();
|
|
if (!"file".equals(scheme) && !"content".equals(scheme)) return null;
|
|
BitmapFactory.Options size = new BitmapFactory.Options();
|
|
size.inJustDecodeBounds = true;
|
|
try (InputStream in = app.getContentResolver().openInputStream(u)) {
|
|
BitmapFactory.decodeStream(in, null, size);
|
|
}
|
|
if (size.outWidth <= 0 || size.outHeight <= 0) return null;
|
|
BitmapFactory.Options small = new BitmapFactory.Options();
|
|
small.inSampleSize = 1;
|
|
while (Math.max(size.outWidth, size.outHeight) / (small.inSampleSize * 2) >= COVER_PX) small.inSampleSize *= 2;
|
|
try (InputStream in = app.getContentResolver().openInputStream(u)) {
|
|
return BitmapFactory.decodeStream(in, null, small);
|
|
}
|
|
} catch (Exception | OutOfMemoryError e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|