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