App 2.45.0: now playing on the Overview

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
This commit is contained in:
2026-11-02 14:03:26 +01:00
co-authored by Claude Opus 5.5
parent bdb3f5e765
commit 91283093f5
15 changed files with 619 additions and 8 deletions
@@ -0,0 +1,215 @@
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;
}
}
}