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 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; } }