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