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
137 lines
5.9 KiB
Java
137 lines
5.9 KiB
Java
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;
|
|
}
|
|
}
|