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
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 159
versionName "2.44.0"
versionCode 160
versionName "2.45.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -23,4 +23,6 @@ final class ClusterValues {
String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
/** Battery power over the last two minutes, oldest first, NaN where unread; null before any. */
float[] powerTrail;
/** What plays ({@link NowPlaying}): the title, null when nothing does or it is off, and the artist or null. */
String songTitle, songArtist;
}
@@ -27,7 +27,7 @@ import java.util.Locale;
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
* light nothing. With music playing, the pumpkin's glow swells and its carving brightens on the
* beat ({@link MusicTap}); colours and the embers keep their meaning, and with no music it burns as
* before.
* before. What plays is carved into the foot of the hill ({@link NowPlaying}).
*
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen;
@@ -99,6 +99,7 @@ final class HalloweenView extends ClusterCanvasView {
drawMoon(c, w, u);
drawGhost(c, w, u);
drawHill(c, w, h, u);
drawSong(c, w, h, u);
drawEmbers(c, w, u);
float pulse = music == null ? 0f : music.pulse();
drawPumpkin(c, w, u, pulse);
@@ -276,6 +277,26 @@ final class HalloweenView extends ClusterCanvasView {
}
}
// ---------- What plays, carved into the hill ----------
private void drawSong(Canvas c, int w, int h, float u) {
if (v.songTitle == null) return;
text.setTypeface(thin);
text.setTextSize(20f * u);
float room = w * 0.44f;
String artist = v.songArtist == null ? "" : " — " + cut(v.songArtist, room / 2f);
// A long title gives way, so the artist stays.
String s = "♪ " + cut(v.songTitle, room - text.measureText("♪ " + artist)) + artist;
label(c, s, w / 2f, h - 12f * u, 20f * u, AccentColor.withAlpha(BONE, 0xC0), thin, Paint.Align.CENTER);
}
/** {@code s} cut with "…" to fit {@code room} in the text paint as set. */
private String cut(String s, float room) {
if (text.measureText(s) <= room) return s;
int fits = text.breakText(s, true, Math.max(0f, room - text.measureText("…")), null);
return s.substring(0, Math.max(0, fits)).trim() + "…";
}
// ---------- The arch of embers: the power ----------
private void drawEmbers(Canvas c, int w, float u) {
@@ -78,6 +78,7 @@ public class MainActivity extends AppCompatActivity {
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
private View[] stylePanes;
private MusicTap musicTap;
private NowPlayingWatcher nowPlaying;
private View consumptionPane;
private View chargingPane;
/** Display, battery, charging price and Wi-Fi. */
@@ -197,6 +198,7 @@ public class MainActivity extends AppCompatActivity {
markCurrentPage(position);
applyOverviewFullScreen(position);
updateMusicTap();
updateNowPlaying();
}
});
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
@@ -215,6 +217,8 @@ public class MainActivity extends AppCompatActivity {
/** Whether the Overview hides the navigation (the button in the navigation's header). */
static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen";
/** The Overview shows what plays (Tiles, Halloween); on by default. */
static final String PREF_NOW_PLAYING = "now_playing";
/** The Halloween pumpkin's light follows the music; off by default, so the analyser stays off. */
static final String PREF_MUSIC_LIGHT = "music_light";
@@ -359,6 +363,7 @@ public class MainActivity extends AppCompatActivity {
visorView = new VisorView(this);
HalloweenView halloweenView = new HalloweenView(this);
musicTap = new MusicTap(this);
nowPlaying = new NowPlayingWatcher(this);
halloweenView.setMusic(musicTap);
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
@@ -514,6 +519,7 @@ public class MainActivity extends AppCompatActivity {
inFront = true;
updateMapLocation();
updateMusicTap();
updateNowPlaying();
}
@Override
@@ -564,6 +570,7 @@ public class MainActivity extends AppCompatActivity {
inFront = false;
updateMapLocation();
updateMusicTap();
updateNowPlaying();
}
@Override
@@ -1225,6 +1232,12 @@ public class MainActivity extends AppCompatActivity {
bindDriveMode();
bindOverviewStyle();
bindAccentColor();
SwitchMaterial showPlaying = settingsPane.findViewById(R.id.now_playing_switch);
showPlaying.setChecked(prefs.getBoolean(PREF_NOW_PLAYING, true));
showPlaying.setOnCheckedChangeListener((btn, checked) -> {
prefs.edit().putBoolean(PREF_NOW_PLAYING, checked).apply();
updateNowPlaying();
});
SwitchMaterial musicLight = settingsPane.findViewById(R.id.music_light_switch);
musicLight.setChecked(prefs.getBoolean(PREF_MUSIC_LIGHT, false));
musicLight.setOnCheckedChangeListener((btn, checked) -> {
@@ -1767,6 +1780,57 @@ public class MainActivity extends AppCompatActivity {
if (settingsPane != null) bindAccentColor();
updateMapLocation();
updateMusicTap();
updateNowPlaying();
}
/** The styles that show what plays. */
private static boolean showsNowPlaying(OverviewStyle style) {
return style == OverviewStyle.TILES || style == OverviewStyle.HALLOWEEN;
}
/** The media sessions are watched only while what plays can be seen, and the switch is on. */
private void updateNowPlaying() {
ViewPager2 pager = findViewById(R.id.content);
nowPlaying.setRunning(inFront && showsNowPlaying(overviewStyle())
&& prefs.getBoolean(PREF_NOW_PLAYING, true)
&& pager != null && pager.getCurrentItem() == 0);
if (!nowPlaying.isRunning()) showNowPlaying(null);
}
/** The Tiles tile: source, title, artist, a cover when there is one, and how far the song is. */
private void showNowPlaying(NowPlaying.Track t) {
if (vehiclePane == null) return;
View tile = vehiclePane.findViewById(R.id.now_playing_tile);
if (tile == null) return; // the narrow layout has no room for it
if (t == null) {
tile.setVisibility(View.INVISIBLE);
return;
}
tile.setVisibility(View.VISIBLE);
String source = getString(t.source == NowPlaying.Source.BLUETOOTH ? R.string.now_playing_bluetooth
: t.source == NowPlaying.Source.CARPLAY ? R.string.now_playing_carplay
: t.source == NowPlaying.Source.RADIO ? R.string.now_playing_radio : R.string.now_playing_other);
setIfChanged(vehiclePane.findViewById(R.id.now_playing_source),
t.playing ? source : getString(R.string.now_playing_paused, source));
setIfChanged(vehiclePane.findViewById(R.id.now_playing_title), t.title);
TextView artist = vehiclePane.findViewById(R.id.now_playing_artist);
setIfChanged(artist, t.subtitle == null ? "" : t.subtitle);
artist.setVisibility(t.subtitle == null ? View.GONE : View.VISIBLE);
android.widget.ImageView cover = vehiclePane.findViewById(R.id.now_playing_cover);
android.graphics.Bitmap art = nowPlaying.cover(t);
if (cover.getTag() != art) {
cover.setTag(art);
cover.setImageBitmap(art);
cover.setVisibility(art == null ? View.GONE : View.VISIBLE);
}
android.widget.ProgressBar bar = vehiclePane.findViewById(R.id.now_playing_progress);
float progress = t.progressAt(android.os.SystemClock.elapsedRealtime());
bar.setVisibility(progress < 0f ? View.INVISIBLE : View.VISIBLE);
if (progress >= 0f) bar.setProgress(Math.round(progress * bar.getMax()));
}
private static void setIfChanged(TextView view, CharSequence text) {
if (!android.text.TextUtils.equals(view.getText(), text)) view.setText(text);
}
/**
@@ -1840,6 +1904,9 @@ public class MainActivity extends AppCompatActivity {
TextView note = settingsPane.findViewById(R.id.accent_note);
note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE);
note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()]));
int playing = showsNowPlaying(style) ? View.VISIBLE : View.GONE;
settingsPane.findViewById(R.id.now_playing_switch).setVisibility(playing);
settingsPane.findViewById(R.id.now_playing_hint).setVisibility(playing);
int music = style == OverviewStyle.HALLOWEEN ? View.VISIBLE : View.GONE;
settingsPane.findViewById(R.id.music_light_switch).setVisibility(music);
settingsPane.findViewById(R.id.music_light_hint).setVisibility(music);
@@ -1925,6 +1992,10 @@ public class MainActivity extends AppCompatActivity {
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
v.powerTrail = cockpitTrace.snapshot();
NowPlaying.Track track = nowPlaying.isRunning() ? nowPlaying.current() : null;
v.songTitle = track == null ? null : track.title;
v.songArtist = track == null ? null : track.subtitle;
showNowPlaying(track);
retroView.setValues(v);
flowView.setValues(v);
sciFiView.setValues(v);
@@ -0,0 +1,136 @@
package com.evsuite.abrp;
import java.util.List;
import java.util.Locale;
import java.util.regex.Pattern;
/**
* What the Overview shows as playing, out of the media sessions the head unit publishes
* ({@link NowPlayingWatcher}): the session that plays, the one updated last when several do, and
* when none plays the one paused last. Sessions without any text are passed over (Bluetooth leaves
* an empty one behind when the phone stops). The text is cleaned: CarPlay's YouTube titles lose
* their trailing video ID, and the radio, whose title is the station and whose artist field is the
* station's own text, shows that text with the station below. Shown on screen only; nothing here
* is logged or sent. Android-free and unit-tested.
*/
final class NowPlaying {
enum Source { BLUETOOTH, CARPLAY, RADIO, OTHER }
/** android.media.session.PlaybackState's codes, mirrored to stay Android-free. */
static final int STATE_PAUSED = 2, STATE_PLAYING = 3, STATE_FAST_FORWARDING = 4, STATE_REWINDING = 5,
STATE_BUFFERING = 6, STATE_SKIPPING_TO_PREVIOUS = 9, STATE_SKIPPING_TO_NEXT = 10,
STATE_SKIPPING_TO_QUEUE_ITEM = 11;
private static final int MAX_TEXT = 200;
/** A YouTube video ID in brackets at the end of a title, as CarPlay passes them on. */
private static final Pattern VIDEO_ID = Pattern.compile("\\s*\\[[A-Za-z0-9_-]{11}\\]$");
private static final Pattern SPACES = Pattern.compile("\\s+");
private NowPlaying() {}
/** One media session as last seen. Times in the platform's elapsed-realtime milliseconds. */
static final class Session {
String packageName;
int state;
String title, artist;
/** -1 when the session says no position. */
long positionMs = -1;
long positionUpdatedAt;
float speed = 1f;
/** 0 when unknown (the radio). */
long durationMs;
/** A file or content URI for the cover, when the session names one (Bluetooth does). */
String artUri;
}
/** What to show. {@code subtitle} may be null; {@code session} is where it came from. */
static final class Track {
final Source source;
final String title, subtitle;
final boolean playing;
final Session session;
Track(Source source, String title, String subtitle, boolean playing, Session session) {
this.source = source;
this.title = title;
this.subtitle = subtitle;
this.playing = playing;
this.session = session;
}
/** Where the song is at {@code now}, or -1 when the session gives no position. */
long positionAt(long now) {
if (session.positionMs < 0) return -1;
long at = session.positionMs;
if (playing && session.positionUpdatedAt > 0 && now > session.positionUpdatedAt) {
at += (long) ((now - session.positionUpdatedAt) * session.speed);
}
if (session.durationMs > 0) at = Math.min(at, session.durationMs);
return Math.max(0, at);
}
/** How far through the song, from 0 to 1, or -1 without a length or position (the radio). */
float progressAt(long now) {
long at = positionAt(now);
if (at < 0 || session.durationMs <= 0) return -1f;
return Math.min(1f, at / (float) session.durationMs);
}
}
static Track pick(List<Session> sessions) {
Session best = null;
boolean bestPlaying = false;
if (sessions != null) {
for (Session s : sessions) {
if (s == null || (!playing(s.state) && s.state != STATE_PAUSED)) continue;
if (clean(s.title) == null && clean(s.artist) == null) continue;
boolean p = playing(s.state);
if (best == null || (p && !bestPlaying)
|| (p == bestPlaying && s.positionUpdatedAt > best.positionUpdatedAt)) {
best = s;
bestPlaying = p;
}
}
}
if (best == null) return null;
Source source = source(best.packageName);
String title = clean(best.title), artist = clean(best.artist);
if (source == Source.RADIO) {
// The station is the title; its own text (song, programme) comes as the artist.
if (artist != null) return new Track(source, artist, title, bestPlaying, best);
return new Track(source, title, null, bestPlaying, best);
}
if (title == null) return new Track(source, artist, null, bestPlaying, best);
return new Track(source, title, artist, bestPlaying, best);
}
static boolean playing(int state) {
switch (state) {
case STATE_PLAYING: case STATE_FAST_FORWARDING: case STATE_REWINDING: case STATE_BUFFERING:
case STATE_SKIPPING_TO_PREVIOUS: case STATE_SKIPPING_TO_NEXT: case STATE_SKIPPING_TO_QUEUE_ITEM:
return true;
default:
return false;
}
}
/** Which source a session's app is: the ones seen on the owner's car, the rest as "other". */
static Source source(String packageName) {
if (packageName == null) return Source.OTHER;
String p = packageName.toLowerCase(Locale.ROOT);
if (p.equals("com.android.bluetooth")) return Source.BLUETOOTH;
if (p.contains("allgo") || p.contains("carplay")) return Source.CARPLAY;
if (p.contains("radio")) return Source.RADIO;
return Source.OTHER;
}
/** Trimmed, one line, without a trailing video ID, at most 200 characters; null when empty. */
static String clean(String text) {
if (text == null) return null;
String s = SPACES.matcher(text).replaceAll(" ").trim();
s = VIDEO_ID.matcher(s).replaceAll("").trim();
if (s.isEmpty()) return null;
return s.length() > MAX_TEXT ? s.substring(0, MAX_TEXT - 1) + "…" : s;
}
}
@@ -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;
}
}
}
@@ -26,6 +26,9 @@
</LinearLayout>
</LinearLayout>
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
<!-- Only the styles that show what plays (Tiles, Halloween). -->
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/now_playing_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_now_playing" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
<TextView android:id="@+id/now_playing_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_now_playing" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
<!-- Only Halloween listens to the music. -->
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/music_light_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_music_light" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
<TextView android:id="@+id/music_light_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_music_light" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
@@ -31,7 +31,18 @@
</LinearLayout>
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:orientation="horizontal" android:baselineAligned="false">
<Space android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" android:layout_marginEnd="10dp" />
<!-- What plays (NowPlaying), in the slot under the battery; invisible when nothing does. -->
<LinearLayout android:id="@+id/now_playing_tile" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass" android:visibility="invisible">
<TextView android:id="@+id/now_playing_source" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
<ImageView android:id="@+id/now_playing_cover" android:layout_width="44dp" android:layout_height="44dp" android:layout_marginEnd="10dp" android:scaleType="centerCrop" android:importantForAccessibility="no" android:visibility="gone" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical">
<TextView android:id="@+id/now_playing_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Body" android:textSize="17sp" android:maxLines="1" android:ellipsize="end" />
<TextView android:id="@+id/now_playing_artist" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="@style/Text.EV.Secondary" android:textColor="?attr/evAccentLight" android:textSize="16sp" android:maxLines="1" android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
<ProgressBar android:id="@+id/now_playing_progress" style="?android:attr/progressBarStyleHorizontal" android:layout_width="match_parent" android:layout_height="4dp" android:layout_marginTop="6dp" android:max="1000" android:progressTint="?attr/evAccent" android:progressBackgroundTint="?attr/evAccentTrack" android:visibility="invisible" />
</LinearLayout>
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:layout_marginEnd="10dp" android:orientation="vertical" android:minHeight="112dp" android:padding="16dp" android:background="@drawable/bg_glass">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/vehicle_row_energy_trip" android:textAppearance="@style/Text.EV.Secondary" />
<TextView android:id="@+id/vehicle_energy_trip" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/no_value" android:layout_marginTop="6dp" android:textAppearance="@style/Text.EV.Metric.Compact" android:gravity="end" android:fontFeatureSettings="tnum" android:maxLines="1" android:autoSizeTextType="uniform" android:autoSizeMinTextSize="18sp" android:autoSizeMaxTextSize="28sp" android:autoSizeStepGranularity="1sp" />
@@ -17,6 +17,9 @@
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
</com.google.android.material.textfield.TextInputLayout>
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
<!-- Only the styles that show what plays (Tiles, Halloween). -->
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/now_playing_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_now_playing" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
<TextView android:id="@+id/now_playing_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_now_playing" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
<!-- Only Halloween listens to the music. -->
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/music_light_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_music_light" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" android:visibility="gone" />
<TextView android:id="@+id/music_light_hint" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_music_light" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
+7
View File
@@ -129,6 +129,13 @@
<string name="overview_style_scifi">Sci-fi</string>
<string name="overview_style_cartoon">Cartoon</string>
<string name="overview_style_mg">MG</string>
<string name="label_now_playing">Show what\'s playing</string>
<string name="hint_now_playing">Title and artist from Bluetooth, CarPlay or the radio. Shown here only, never sent.</string>
<string name="now_playing_bluetooth">Bluetooth</string>
<string name="now_playing_carplay">CarPlay</string>
<string name="now_playing_radio">Radio</string>
<string name="now_playing_other">Media</string>
<string name="now_playing_paused">%1$s · paused</string>
<string name="label_music_light">Pumpkin light follows the music</string>
<string name="hint_music_light">Flares on the kick drum over Bluetooth or CarPlay, not the radio. Levels only, no sound kept.</string>
<string name="accent_not_used">%1$s has its own colours, and the app takes them on. To change the accent colour, pick a style that uses it.</string>
@@ -0,0 +1,138 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collections;
import org.junit.Test;
public class NowPlayingTest {
private static NowPlaying.Session session(String pkg, int state, String title, String artist, long updatedAt) {
NowPlaying.Session s = new NowPlaying.Session();
s.packageName = pkg;
s.state = state;
s.title = title;
s.artist = artist;
s.positionUpdatedAt = updatedAt;
return s;
}
@Test
public void nothingToShow() {
assertNull(NowPlaying.pick(null));
assertNull(NowPlaying.pick(Collections.emptyList()));
// Bluetooth's empty session after the phone stopped, and one that only connects.
assertNull(NowPlaying.pick(Arrays.asList(
session("com.android.bluetooth", 1, "", "", 5),
session("com.android.car.radio", 8, "Radio", null, 6),
session("com.android.bluetooth", NowPlaying.STATE_PLAYING, " ", null, 7))));
}
@Test
public void whatPlaysWinsOverWhatIsPaused() {
// As on the car on 2026-10-28: CarPlay paused, Bluetooth playing.
NowPlaying.Track t = NowPlaying.pick(Arrays.asList(
session("com.allgo.remoteui.mediabrowserservice", NowPlaying.STATE_PAUSED, "A Mix", "Bass Bangers", 900),
session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Paramount", "Mark Petrie & Andrew Prahlow", 100)));
assertEquals(NowPlaying.Source.BLUETOOTH, t.source);
assertEquals("Paramount", t.title);
assertEquals("Mark Petrie & Andrew Prahlow", t.subtitle);
assertTrue(t.playing);
}
@Test
public void ofTwoPlayingTheNewerWins() {
NowPlaying.Track t = NowPlaying.pick(Arrays.asList(
session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Old", null, 100),
session("com.allgo.remoteui.mediabrowserservice", NowPlaying.STATE_BUFFERING, "New", null, 200)));
assertEquals(NowPlaying.Source.CARPLAY, t.source);
assertEquals("New", t.title);
}
@Test
public void withNothingPlayingThePausedOneShows() {
NowPlaying.Track t = NowPlaying.pick(Arrays.asList(
session("com.android.bluetooth", NowPlaying.STATE_PAUSED, "Earlier", null, 100),
session("com.allgo.remoteui.mediabrowserservice", NowPlaying.STATE_PAUSED, "Later", null, 300)));
assertEquals("Later", t.title);
assertFalse(t.playing);
}
@Test
public void theRadioShowsItsTextWithTheStationBelow() {
NowPlaying.Track t = NowPlaying.pick(Collections.singletonList(
session("com.saicmotor.radio", NowPlaying.STATE_PLAYING, "Brillux Radio", "BLIND MELON - NO RAIN", 1)));
assertEquals(NowPlaying.Source.RADIO, t.source);
assertEquals("BLIND MELON - NO RAIN", t.title);
assertEquals("Brillux Radio", t.subtitle);
NowPlaying.Track station = NowPlaying.pick(Collections.singletonList(
session("com.saicmotor.radio", NowPlaying.STATE_PLAYING, "Brillux Radio", "", 1)));
assertEquals("Brillux Radio", station.title);
assertNull(station.subtitle);
assertEquals(-1f, station.progressAt(5), 0f);
}
@Test
public void textIsCleaned() {
assertEquals("A Letter to the Past (A Seasonal Feels Mix) Ft. Porter Robinson & Friends By SAINT",
NowPlaying.clean("A Letter to the Past (A Seasonal Feels Mix) Ft. Porter Robinson & Friends By SAINT [X4nayMeMBuQ]"));
assertEquals("Live [Remastered 2011]", NowPlaying.clean("Live [Remastered 2011]"));
assertEquals("two lines", NowPlaying.clean(" two\n lines "));
assertNull(NowPlaying.clean(" "));
assertNull(NowPlaying.clean(null));
StringBuilder longText = new StringBuilder();
for (int i = 0; i < 300; i++) longText.append('x');
assertEquals(200, NowPlaying.clean(longText.toString()).length());
}
@Test
public void onlyAnArtistStillShows() {
NowPlaying.Track t = NowPlaying.pick(Collections.singletonList(
session("com.example.player", NowPlaying.STATE_PLAYING, null, "Someone", 1)));
assertEquals(NowPlaying.Source.OTHER, t.source);
assertEquals("Someone", t.title);
assertNull(t.subtitle);
}
@Test
public void thePositionRunsOnWhilePlayingAndStopsAtTheEnd() {
NowPlaying.Session s = session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Paramount", "M", 10_000);
s.positionMs = 3_000;
s.durationMs = 215_000;
NowPlaying.Track t = NowPlaying.pick(Collections.singletonList(s));
assertEquals(3_000, t.positionAt(10_000));
assertEquals(8_000, t.positionAt(15_000));
assertEquals(215_000, t.positionAt(10_000_000));
assertEquals(1f, t.progressAt(10_000_000), 0f);
assertEquals(8_000 / 215_000f, t.progressAt(15_000), 1e-6f);
}
@Test
public void aPausedPositionStaysPut() {
NowPlaying.Session s = session("com.android.bluetooth", NowPlaying.STATE_PAUSED, "Paramount", null, 10_000);
s.positionMs = 3_000;
s.durationMs = 215_000;
assertEquals(3_000, NowPlaying.pick(Collections.singletonList(s)).positionAt(99_000));
}
@Test
public void noPositionNoBar() {
NowPlaying.Session s = session("com.android.bluetooth", NowPlaying.STATE_PLAYING, "Paramount", null, 10_000);
s.durationMs = 215_000;
assertEquals(-1f, NowPlaying.pick(Collections.singletonList(s)).progressAt(20_000), 0f);
}
@Test
public void sourcesAreKnownByTheirApps() {
assertEquals(NowPlaying.Source.BLUETOOTH, NowPlaying.source("com.android.bluetooth"));
assertEquals(NowPlaying.Source.CARPLAY, NowPlaying.source("com.allgo.remoteui.mediabrowserservice"));
assertEquals(NowPlaying.Source.RADIO, NowPlaying.source("com.saicmotor.radio"));
assertEquals(NowPlaying.Source.OTHER, NowPlaying.source("com.android.music"));
assertEquals(NowPlaying.Source.OTHER, NowPlaying.source(null));
}
}