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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user