v0.19.0: andTunes plays music on the Rabbit

The Android half of andTunes had been paused on a 1 GB toolchain download
(Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed:
platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that
links no libraries needs five SDK steps, not a build system. andtunes/build.py
runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK.

andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a
streaming library.json parse grouped into artists and albums in one pass, one
ListActivity for every list (songs and artist songs open with Shuffle all,
albums carry art thumbs, search as you type), a Now playing bar under every
list, and a white Now Playing screen with square art, a slim scrubber,
prev/play/next, shuffle and repeat, and no back button. A foreground
PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and
a MediaStyle notification, end of queue pauses, missing files skip, and the
last queue and position come back on launch. Black on white, and every size
doubled for the R1's override density. Cold start to the menu: 313-338 ms.

The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not
volume. The app takes both in dispatchKeyEvent: lists scroll a row per
detent, and the menu and Now Playing get volume.

LinTunes side: Connections > Install andTunes on Rabbit... installs the
committed APK over adb and grants all-files access, or copies it to Download/
over MTP when there's no adb. Sync fills Buttons/ with default artwork only
where a file is missing, so trav's own art is never overwritten.

Verified against the dev library only, synced to the R1 and driven with
adb input + screencap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
This commit is contained in:
2026-09-11 14:25:55 -05:00
co-authored by Claude Opus 5
parent 8188a5b389
commit 9483d8951b
51 changed files with 2963 additions and 98 deletions
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="me.teafry.andtunes">
<!-- library.json isn't media, so READ_MEDIA_AUDIO can't open it: all-files
access is the whole reason this app can skip scanning. LinTunes grants
it over adb at install; the first-run screen is the fallback. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:label="andTunes"
android:icon="@drawable/ic_launcher"
android:theme="@style/AndTunes"
android:allowBackup="false">
<activity
android:name=".MenuActivity"
android:exported="true"
android:launchMode="singleTop">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ListActivity" android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity android:name=".NowPlayingActivity" android:exported="false"
android:launchMode="singleTop" />
<service android:name=".PlaybackService" android:exported="false"
android:foregroundServiceType="mediaPlayback" />
</application>
</manifest>
@@ -0,0 +1,109 @@
package me.teafry.andtunes;
import android.app.Activity;
import android.content.Intent;
import android.media.AudioManager;
import android.os.Bundle;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
/**
* What every andTunes screen shares: the scroll wheel, and the "Now playing"
* bar along the bottom.
*
* <p>The R1's wheel arrives as DPAD_UP / DPAD_DOWN (its key layout is the stock
* Generic.kl), but a ROM could map it to VOLUME_UP / DOWN instead, so both are
* claimed. They're taken in {@link #dispatchKeyEvent} — before any focused view
* (the search box, a list) can swallow them — and on key-up as well as
* key-down, or the system can still act on the release. This only applies
* while an andTunes screen is in front; volume behaves normally everywhere else.
*/
abstract class BaseActivity extends Activity implements PlaybackService.Listener {
protected AudioManager audio;
private TextView nowBar;
@Override protected void onCreate(Bundle state) {
super.onCreate(state);
audio = getSystemService(AudioManager.class);
setVolumeControlStream(AudioManager.STREAM_MUSIC);
}
/** Lay out {@code content} above a now-playing bar (shown only when useful). */
protected void setScreen(View content, boolean withNowBar) {
LinearLayout column = new LinearLayout(this);
column.setOrientation(LinearLayout.VERTICAL);
column.setBackgroundColor(Ui.WHITE);
column.addView(content, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
if (withNowBar) {
nowBar = Ui.text(this, 24, true, Ui.WHITE);
nowBar.setBackgroundColor(Ui.BLACK);
nowBar.setGravity(Gravity.CENTER_VERTICAL);
int pad = Ui.dp(this, 20);
nowBar.setPadding(pad, 0, pad, 0);
nowBar.setOnClickListener(v -> openNowPlaying());
column.addView(nowBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 80)));
}
setContentView(column);
}
protected void openNowPlaying() {
startActivity(new Intent(this, NowPlayingActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT));
}
@Override protected void onResume() {
super.onResume();
PlaybackService.addListener(this);
onPlaybackChanged();
}
@Override protected void onPause() {
PlaybackService.removeListener(this);
super.onPause();
}
@Override public void onPlaybackChanged() {
if (nowBar == null) return;
PlaybackService s = PlaybackService.get();
Library.Track t = s != null ? s.current() : null;
if (t == null) {
nowBar.setVisibility(View.GONE);
return;
}
nowBar.setVisibility(View.VISIBLE);
nowBar.setText((s.isPlaying() ? "Now playing: " : "Paused: ") + t.title);
}
@Override public boolean dispatchKeyEvent(KeyEvent e) {
int dir = wheelDirection(e.getKeyCode());
if (dir == 0) return super.dispatchKeyEvent(e);
if (e.getAction() == KeyEvent.ACTION_DOWN) onWheel(dir);
return true;
}
static int wheelDirection(int code) {
switch (code) {
case KeyEvent.KEYCODE_DPAD_UP:
case KeyEvent.KEYCODE_VOLUME_UP:
return 1;
case KeyEvent.KEYCODE_DPAD_DOWN:
case KeyEvent.KEYCODE_VOLUME_DOWN:
return -1;
default:
return 0;
}
}
/** One wheel detent. Volume unless the screen has something to scroll. */
protected void onWheel(int dir) {
audio.adjustStreamVolume(AudioManager.STREAM_MUSIC,
dir > 0 ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER,
AudioManager.FLAG_SHOW_UI);
}
}
@@ -0,0 +1,312 @@
package me.teafry.andtunes;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.util.JsonReader;
import android.util.JsonToken;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
/**
* Everything andTunes knows, read from the one file LinTunes wrote.
*
* This is the whole bet: no MediaStore, no directory walk, no tag reads.
* {@code library.json} is parsed with a streaming reader on a background
* thread (kicked off by the menu, which itself loads nothing), then artists
* and albums are grouped from the flat track list in one pass. The result is
* immutable and lives for the life of the process, so every screen after the
* first is instant.
*/
final class Library {
static final int FORMAT = 1;
static final String UNKNOWN_ARTIST = "Unknown Artist";
static final String UNKNOWN_ALBUM = "Unknown Album";
static final class Track {
long id;
String title = "", path = "", artist = "", albumArtist = "", album = "", art = "";
int year, number, disc, secs;
String sortKey, haystack;
Album albumRef;
File file() { return new File(root(), path); }
String artistLabel() { return artist.isEmpty() ? UNKNOWN_ARTIST : artist; }
}
static final class Album {
String key, name, artist, art = "";
int year;
String sortKey;
final ArrayList<Track> tracks = new ArrayList<>();
}
static final class Artist {
String name, sortKey;
final ArrayList<Album> albums = new ArrayList<>();
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
}
static final class Playlist {
String id, name;
final ArrayList<Track> tracks = new ArrayList<>(); // playing order
}
interface Listener { void onLibrary(Library library, String error); }
final ArrayList<Track> tracks = new ArrayList<>(); // alphabetical
final ArrayList<Artist> artists = new ArrayList<>();
final ArrayList<Album> albums = new ArrayList<>();
final ArrayList<Playlist> playlists = new ArrayList<>();
final HashMap<Long, Track> byId = new HashMap<>();
final HashMap<String, Artist> artistsByName = new HashMap<>();
final HashMap<String, Album> albumsByKey = new HashMap<>();
long stamp; // library.json mtime + size, to notice a re-sync
// ---- process-wide instance ----
private static final Handler MAIN = new Handler(Looper.getMainLooper());
private static Library sLibrary;
private static String sError;
private static boolean sLoading;
private static final ArrayList<Listener> sWaiting = new ArrayList<>();
static File root() {
return new File(Environment.getExternalStorageDirectory(), "Music/andTunes");
}
static File manifest() { return new File(root(), "library.json"); }
static Library get() { return sLibrary; }
/** Start loading if nothing is loaded yet, or the file changed under us. */
static void preload() {
if (sLoading) return;
if (sLibrary != null && sLibrary.stamp == stampOf(manifest())) return;
sLoading = true;
new Thread(() -> {
Library lib = null;
String err = null;
try {
lib = load(manifest());
} catch (IOException | RuntimeException e) {
err = describe(e);
}
final Library done = lib;
final String error = err;
MAIN.post(() -> {
sLoading = false;
if (done != null) { sLibrary = done; sError = null; }
else if (sLibrary == null) sError = error;
ArrayList<Listener> waiting = new ArrayList<>(sWaiting);
sWaiting.clear();
for (Listener l : waiting) l.onLibrary(sLibrary, sError);
});
}, "library-load").start();
}
/** Call back on the main thread once a library (or an error) is ready. */
static void whenReady(Listener listener) {
if (!sLoading && (sLibrary != null || sError != null)) {
listener.onLibrary(sLibrary, sError);
return;
}
sWaiting.add(listener);
preload();
}
static void forget(Listener listener) { sWaiting.remove(listener); }
private static long stampOf(File f) { return f.lastModified() * 31 + f.length(); }
private static String describe(Exception e) {
if (e instanceof java.io.FileNotFoundException) {
return "No music yet.\n\nIn LinTunes: Connections → Sync to Rabbit.";
}
String msg = e.getMessage();
return msg != null && msg.startsWith("andTunes:") ? msg.substring(9)
: "Couldn't read library.json:\n" + e;
}
// ---- parsing ----
static Library load(File file) throws IOException {
Library lib = new Library();
lib.stamp = stampOf(file);
HashMap<String, List<Long>> playlistIds = new HashMap<>();
int format = -1;
try (JsonReader r = new JsonReader(new InputStreamReader(
new FileInputStream(file), StandardCharsets.UTF_8))) {
r.beginObject();
while (r.hasNext()) {
String key = r.nextName();
if (key.equals("format")) {
format = r.nextInt();
checkFormat(format);
} else if (key.equals("tracks")) {
r.beginArray();
while (r.hasNext()) lib.addTrack(readTrack(r));
r.endArray();
} else if (key.equals("playlists")) {
r.beginArray();
while (r.hasNext()) lib.playlists.add(readPlaylist(r, playlistIds));
r.endArray();
} else {
r.skipValue();
}
}
r.endObject();
}
checkFormat(format);
for (Playlist p : lib.playlists) {
for (Long id : playlistIds.get(p.id)) {
Track t = lib.byId.get(id);
if (t != null) p.tracks.add(t);
}
}
lib.group();
return lib;
}
private static void checkFormat(int format) {
if (format != FORMAT) {
throw new IllegalStateException("andTunes:This library was written in format "
+ format + ", and this andTunes only reads format " + FORMAT
+ ".\n\nIn LinTunes: Connections → Install andTunes on Rabbit.");
}
}
private static Track readTrack(JsonReader r) throws IOException {
Track t = new Track();
r.beginObject();
while (r.hasNext()) {
String key = r.nextName();
if (r.peek() == JsonToken.NULL) { r.skipValue(); continue; }
switch (key) {
case "id": t.id = r.nextLong(); break;
case "title": t.title = r.nextString(); break;
case "path": t.path = r.nextString(); break;
case "artist": t.artist = r.nextString(); break;
case "album_artist": t.albumArtist = r.nextString(); break;
case "album": t.album = r.nextString(); break;
case "art": t.art = r.nextString(); break;
case "year": t.year = r.nextInt(); break;
case "track": t.number = r.nextInt(); break;
case "disc": t.disc = r.nextInt(); break;
case "secs": t.secs = r.nextInt(); break;
default: r.skipValue();
}
}
r.endObject();
if (t.title.isEmpty()) {
String name = new File(t.path).getName();
int dot = name.lastIndexOf('.');
t.title = dot > 0 ? name.substring(0, dot) : name;
}
return t;
}
private static Playlist readPlaylist(JsonReader r, HashMap<String, List<Long>> ids)
throws IOException {
Playlist p = new Playlist();
ArrayList<Long> members = new ArrayList<>();
r.beginObject();
while (r.hasNext()) {
String key = r.nextName();
switch (key) {
case "id": p.id = r.nextString(); break;
case "name": p.name = r.nextString(); break;
case "tracks":
r.beginArray();
while (r.hasNext()) members.add(r.nextLong());
r.endArray();
break;
default: r.skipValue();
}
}
r.endObject();
if (p.id == null) p.id = "p" + ids.size();
if (p.name == null) p.name = "Untitled";
ids.put(p.id, members);
return p;
}
private void addTrack(Track t) {
t.sortKey = sortKey(t.title);
t.haystack = (t.title + "\n" + t.artist + "\n" + t.albumArtist + "\n" + t.album)
.toLowerCase(Locale.ROOT);
tracks.add(t);
byId.put(t.id, t);
}
/** Artists and albums, grouped from the flat list in one pass. */
private void group() {
Collections.sort(tracks, (a, b) -> a.sortKey.compareTo(b.sortKey));
for (Track t : tracks) {
// Grouped by album artist when there is one, the way the desktop
// files them, so a compilation stays one album under one name.
String who = !t.albumArtist.isEmpty() ? t.albumArtist
: !t.artist.isEmpty() ? t.artist : UNKNOWN_ARTIST;
String albumName = t.album.isEmpty() ? UNKNOWN_ALBUM : t.album;
String key = who.toLowerCase(Locale.ROOT) + ""
+ albumName.toLowerCase(Locale.ROOT);
Album album = albumsByKey.get(key);
if (album == null) {
album = new Album();
album.key = key;
album.name = albumName;
album.artist = who;
album.sortKey = sortKey(albumName);
albumsByKey.put(key, album);
albums.add(album);
Artist artist = artistsByName.get(who);
if (artist == null) {
artist = new Artist();
artist.name = who;
artist.sortKey = sortKey(who);
artistsByName.put(who, artist);
artists.add(artist);
}
artist.albums.add(album);
}
album.tracks.add(t);
if (album.art.isEmpty() && !t.art.isEmpty()) album.art = t.art;
if (t.year > 0 && (album.year == 0 || t.year < album.year)) album.year = t.year;
t.albumRef = album;
artistsByName.get(who).tracks.add(t);
}
for (Album a : albums) {
Collections.sort(a.tracks, (x, y) -> x.disc != y.disc ? x.disc - y.disc
: x.number != y.number ? x.number - y.number
: x.sortKey.compareTo(y.sortKey));
}
Collections.sort(albums, (a, b) -> a.sortKey.compareTo(b.sortKey));
Collections.sort(artists, (a, b) -> a.sortKey.compareTo(b.sortKey));
for (Artist a : artists) {
// An artist's own albums read best oldest first.
Collections.sort(a.albums, (x, y) -> x.year != y.year ? x.year - y.year
: x.sortKey.compareTo(y.sortKey));
}
}
/** iTunes-style: case-folded, with a leading "The", "A" or "An" ignored. */
static String sortKey(String s) {
String k = s.trim().toLowerCase(Locale.ROOT);
for (String article : new String[] {"the ", "a ", "an "}) {
if (k.startsWith(article) && k.length() > article.length()) {
k = k.substring(article.length());
break;
}
}
return k;
}
}
@@ -0,0 +1,400 @@
package me.teafry.andtunes;
import android.content.Intent;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputMethodManager;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.GradientDrawable;
import android.widget.BaseAdapter;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import java.util.ArrayList;
import java.util.Locale;
/**
* Every list in the app, parameterised by {@link #MODE}. Screens are separate
* activities so "go back to where I was" is just Android's back stack.
*/
public class ListActivity extends BaseActivity {
static final String MODE = "mode", ID = "id";
static final String PLAYLISTS = "playlists", PLAYLIST = "playlist",
ARTISTS = "artists", ARTIST = "artist", ARTIST_SONGS = "artist_songs",
ALBUMS = "albums", ALBUM = "album", SONGS = "songs", SEARCH = "search";
private static final int SHUFFLE = 0, SONG = 1, PLAYLIST_ROW = 2, ARTIST_ROW = 3,
ALBUM_ROW = 4, ALL_SONGS = 5, HEADER = 6, NOTE = 7;
private static final int SEARCH_CAP = 300;
private static final class Row {
final int kind;
final String title, sub;
final Object ref; // Track / Playlist / Artist / Album
final int songIndex; // into songs, for SONG rows
Row(int kind, String title, String sub, Object ref, int songIndex) {
this.kind = kind; this.title = title; this.sub = sub;
this.ref = ref; this.songIndex = songIndex;
}
}
private String mode, id;
private TextView titleBar;
private ListView list;
private EditText searchBox;
private final RowAdapter adapter = new RowAdapter();
private final ArrayList<Row> rows = new ArrayList<>();
private final ArrayList<Library.Track> songs = new ArrayList<>();
private Library library;
private final Library.Listener onLoaded = (lib, error) -> populate(lib, error);
@Override protected void onCreate(Bundle state) {
super.onCreate(state);
mode = getIntent().getStringExtra(MODE);
id = getIntent().getStringExtra(ID);
if (mode == null) mode = SONGS;
LinearLayout column = new LinearLayout(this);
column.setOrientation(LinearLayout.VERTICAL);
titleBar = Ui.text(this, 26, true, Ui.WHITE);
titleBar.setBackgroundColor(Ui.BLACK);
titleBar.setGravity(Gravity.CENTER_VERTICAL);
int pad = Ui.dp(this, 20);
titleBar.setPadding(pad, 0, pad, 0);
column.addView(titleBar, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 64)));
if (SEARCH.equals(mode)) column.addView(buildSearchBox());
list = new ListView(this);
list.setDivider(new ColorDrawable(Ui.BLACK));
list.setDividerHeight(Ui.dp(this, 1));
list.setBackgroundColor(Ui.WHITE);
list.setAdapter(adapter);
list.setOnItemClickListener((parent, view, position, rowId) -> onRow(rows.get(position)));
column.addView(list, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
setScreen(column, true);
titleBar.setText(defaultTitle());
rows.add(new Row(NOTE, "Loading…", null, null, -1));
Library.whenReady(onLoaded);
}
@Override protected void onDestroy() {
Library.forget(onLoaded);
super.onDestroy();
}
@Override public void onPlaybackChanged() {
super.onPlaybackChanged();
adapter.notifyDataSetChanged(); // re-mark the playing row
}
@Override protected void onWheel(int dir) {
list.scrollListBy(-dir * Ui.dp(this, Ui.ROW_DP));
}
private String defaultTitle() {
switch (mode) {
case PLAYLISTS: return "Playlists";
case ARTISTS: return "Artists";
case ALBUMS: return "Albums";
case SEARCH: return "Search";
default: return "Songs";
}
}
// ---- building the rows ----
private void populate(Library lib, String error) {
library = lib;
rows.clear();
songs.clear();
if (lib == null) {
rows.add(new Row(NOTE, "No library", error, null, -1));
adapter.notifyDataSetChanged();
return;
}
switch (mode) {
case PLAYLISTS:
for (Library.Playlist p : lib.playlists) {
rows.add(new Row(PLAYLIST_ROW, p.name, count(p.tracks.size()), p, -1));
}
if (lib.playlists.isEmpty()) note("No playlists synced yet");
break;
case PLAYLIST: {
Library.Playlist p = findPlaylist(lib);
if (p == null) { note("This playlist isn't on the Rabbit anymore"); break; }
titleBar.setText(p.name);
songList(p.tracks, "Shuffle", false);
break;
}
case ARTISTS:
for (Library.Artist a : lib.artists) {
rows.add(new Row(ARTIST_ROW, a.name, count(a.tracks.size()), a, -1));
}
break;
case ARTIST: {
Library.Artist a = lib.artistsByName.get(id);
if (a == null) { note("Not in the library anymore"); break; }
titleBar.setText(a.name);
rows.add(new Row(ALL_SONGS, "All songs", count(a.tracks.size()), a, -1));
for (Library.Album al : a.albums) {
rows.add(new Row(ALBUM_ROW, al.name,
al.year > 0 ? String.valueOf(al.year) : count(al.tracks.size()), al, -1));
}
break;
}
case ARTIST_SONGS: {
Library.Artist a = lib.artistsByName.get(id);
if (a == null) { note("Not in the library anymore"); break; }
titleBar.setText(a.name);
songList(a.tracks, "Shuffle all", false);
break;
}
case ALBUMS:
for (Library.Album al : lib.albums) {
rows.add(new Row(ALBUM_ROW, al.name, al.artist, al, -1));
}
break;
case ALBUM: {
Library.Album al = lib.albumsByKey.get(id);
if (al == null) { note("Not in the library anymore"); break; }
titleBar.setText(al.name);
songList(al.tracks, "Shuffle", true);
break;
}
case SEARCH:
search(searchBox.getText().toString());
return;
default:
songList(lib.tracks, "Shuffle all", false);
}
adapter.notifyDataSetChanged();
}
private void songList(ArrayList<Library.Track> tracks, String shuffleLabel, boolean numbered) {
if (tracks.isEmpty()) { note("No songs"); return; }
rows.add(new Row(SHUFFLE, shuffleLabel, count(tracks.size()), null, -1));
for (Library.Track t : tracks) addSong(t, numbered);
}
private void addSong(Library.Track t, boolean numbered) {
String sub;
if (numbered) {
sub = (t.number > 0 ? t.number + " · " : "") + (t.secs > 0 ? Ui.time(t.secs * 1000) : "");
} else {
sub = t.artistLabel();
}
rows.add(new Row(SONG, t.title, sub, t, songs.size()));
songs.add(t);
}
private void note(String text) { rows.add(new Row(NOTE, text, null, null, -1)); }
private Library.Playlist findPlaylist(Library lib) {
for (Library.Playlist p : lib.playlists) if (p.id.equals(id)) return p;
return null;
}
private static String count(int n) { return n == 1 ? "1 song" : n + " songs"; }
// ---- search ----
private View buildSearchBox() {
searchBox = new EditText(this);
searchBox.setTextSize(TypedValue.COMPLEX_UNIT_SP, 28);
searchBox.setTextColor(Ui.BLACK);
searchBox.setHintTextColor(Ui.DIM);
searchBox.setHint("Song, artist or album");
searchBox.setSingleLine(true);
searchBox.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
searchBox.setImeOptions(EditorInfo.IME_ACTION_SEARCH);
GradientDrawable border = new GradientDrawable();
border.setColor(Ui.WHITE);
border.setStroke(Ui.dp(this, 3), Ui.BLACK);
searchBox.setBackground(border);
int pad = Ui.dp(this, 16);
searchBox.setPadding(pad, pad, pad, pad);
searchBox.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence s, int a, int b, int c) { }
@Override public void onTextChanged(CharSequence s, int a, int b, int c) { }
@Override public void afterTextChanged(Editable s) {
if (library != null) search(s.toString());
}
});
searchBox.setOnEditorActionListener((v, action, event) -> {
getSystemService(InputMethodManager.class).hideSoftInputFromWindow(v.getWindowToken(), 0);
return true;
});
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE);
searchBox.requestFocus();
return searchBox;
}
private void search(String raw) {
rows.clear();
songs.clear();
String q = raw.trim().toLowerCase(Locale.ROOT);
if (q.isEmpty()) {
adapter.notifyDataSetChanged();
return;
}
int before = rows.size();
rows.add(new Row(HEADER, "Artists", null, null, -1));
for (Library.Artist a : library.artists) {
if (a.name.toLowerCase(Locale.ROOT).contains(q)) {
rows.add(new Row(ARTIST_ROW, a.name, count(a.tracks.size()), a, -1));
}
}
if (rows.size() == before + 1) rows.remove(before);
before = rows.size();
rows.add(new Row(HEADER, "Albums", null, null, -1));
for (Library.Album al : library.albums) {
if (al.name.toLowerCase(Locale.ROOT).contains(q)) {
rows.add(new Row(ALBUM_ROW, al.name, al.artist, al, -1));
}
}
if (rows.size() == before + 1) rows.remove(before);
before = rows.size();
rows.add(new Row(HEADER, "Songs", null, null, -1));
for (Library.Track t : library.tracks) {
if (t.haystack.contains(q)) {
addSong(t, false);
if (songs.size() >= SEARCH_CAP) break;
}
}
if (rows.size() == before + 1) rows.remove(before);
if (rows.isEmpty()) note("Nothing matches “" + raw.trim() + "”");
adapter.notifyDataSetChanged();
list.setSelection(0);
}
// ---- taps ----
private void onRow(Row row) {
switch (row.kind) {
case SHUFFLE:
PlaybackService.play(this, songs, -1, true);
openNowPlaying();
break;
case SONG:
PlaybackService.play(this, songs, row.songIndex, null);
openNowPlaying();
break;
case PLAYLIST_ROW:
open(PLAYLIST, ((Library.Playlist) row.ref).id);
break;
case ARTIST_ROW:
open(ARTIST, ((Library.Artist) row.ref).name);
break;
case ALL_SONGS:
open(ARTIST_SONGS, ((Library.Artist) row.ref).name);
break;
case ALBUM_ROW:
open(ALBUM, ((Library.Album) row.ref).key);
break;
}
}
private void open(String newMode, String newId) {
startActivity(new Intent(this, ListActivity.class)
.putExtra(MODE, newMode).putExtra(ID, newId));
}
// ---- rows on screen ----
private static final class Holder {
ImageView art;
TextView title, sub;
}
private final class RowAdapter extends BaseAdapter {
@Override public int getCount() { return rows.size(); }
@Override public Object getItem(int i) { return rows.get(i); }
@Override public long getItemId(int i) { return i; }
@Override public boolean isEnabled(int i) {
int k = rows.get(i).kind;
return k != HEADER && k != NOTE;
}
@Override public boolean areAllItemsEnabled() { return false; }
@Override public View getView(int i, View convert, ViewGroup parent) {
Holder h;
if (convert == null) {
h = new Holder();
LinearLayout line = new LinearLayout(ListActivity.this);
line.setOrientation(LinearLayout.HORIZONTAL);
line.setGravity(Gravity.CENTER_VERTICAL);
int pad = Ui.dp(ListActivity.this, 16);
line.setPadding(pad, Ui.dp(ListActivity.this, 8), pad, Ui.dp(ListActivity.this, 8));
h.art = new ImageView(ListActivity.this);
h.art.setScaleType(ImageView.ScaleType.CENTER_CROP);
int size = Ui.dp(ListActivity.this, 72);
LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams(size, size);
alp.rightMargin = pad;
line.addView(h.art, alp);
LinearLayout texts = new LinearLayout(ListActivity.this);
texts.setOrientation(LinearLayout.VERTICAL);
h.title = Ui.text(ListActivity.this, Ui.TITLE_SP, false, Ui.BLACK);
h.sub = Ui.text(ListActivity.this, Ui.SUB_SP, false, Ui.DIM);
texts.addView(h.title);
LinearLayout.LayoutParams slp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
slp.topMargin = Ui.dp(ListActivity.this, 4);
texts.addView(h.sub, slp);
line.addView(texts, new LinearLayout.LayoutParams(0,
ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
line.setTag(h);
convert = line;
} else {
h = (Holder) convert.getTag();
}
Row row = rows.get(i);
boolean header = row.kind == HEADER;
convert.setMinimumHeight(Ui.dp(ListActivity.this, header ? 48 : Ui.ROW_DP));
h.title.setText(row.title);
h.title.setTypeface(null, row.kind == SHUFFLE || row.kind == ALL_SONGS || header
? android.graphics.Typeface.BOLD : android.graphics.Typeface.NORMAL);
h.title.setTextSize(TypedValue.COMPLEX_UNIT_SP, header ? 20 : Ui.TITLE_SP);
h.title.setSingleLine(row.kind != NOTE);
h.sub.setText(row.sub);
h.sub.setSingleLine(row.kind != NOTE);
h.sub.setVisibility(row.sub == null || row.sub.isEmpty() ? View.GONE : View.VISIBLE);
if (row.kind == ALBUM_ROW) {
h.art.setVisibility(View.VISIBLE);
Ui.art(h.art, ((Library.Album) row.ref).art, Ui.dp(ListActivity.this, 72));
} else {
h.art.setVisibility(View.GONE);
h.art.setTag(null);
}
// The playing song is drawn inverted, so it can be found at a glance.
PlaybackService s = PlaybackService.get();
boolean playing = row.kind == SONG && s != null && s.current() == row.ref;
convert.setBackgroundColor(playing ? Ui.BLACK : header ? Ui.PLACEHOLDER : Ui.WHITE);
h.title.setTextColor(playing ? Ui.WHITE : Ui.BLACK);
h.sub.setTextColor(playing ? Ui.WHITE : Ui.DIM);
return convert;
}
}
}
@@ -0,0 +1,173 @@
package me.teafry.andtunes;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.provider.Settings;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
/**
* The first screen, and the number the project is judged on: six tiles,
* visible in well under 400 ms. It <b>loads nothing</b> — it only starts the
* library parse in the background, so the data is usually ready by the time a
* finger lands on a tile.
*/
public class MenuActivity extends BaseActivity {
private static final String[] MODES = {
ListActivity.PLAYLISTS, ListActivity.ARTISTS, ListActivity.ALBUMS,
ListActivity.SONGS, ListActivity.SEARCH};
private static final String[] NAMES = {"playlists", "artists", "albums", "songs", "search"};
private static final String[] LABELS = {"Playlists", "Artists", "Albums", "Songs", "Search"};
private static final int[] ART = {
R.drawable.playlists, R.drawable.artists, R.drawable.albums,
R.drawable.songs, R.drawable.search};
private ImageView sixth;
private Boolean builtWithAccess;
private boolean sixthShowsNowPlaying;
@Override protected void onCreate(Bundle state) {
super.onCreate(state);
build();
}
@Override protected void onResume() {
super.onResume();
if (builtWithAccess != hasAccess()) build();
if (hasAccess()) {
Library.preload(); // no-op unless library.json changed
PlaybackService.restore(this);
}
}
private void build() {
builtWithAccess = hasAccess();
if (!builtWithAccess) {
setScreen(setupView(), false);
return;
}
Library.preload();
if (Build.VERSION.SDK_INT >= 33
&& checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(new String[] {Manifest.permission.POST_NOTIFICATIONS}, 1);
}
LinearLayout grid = new LinearLayout(this);
grid.setOrientation(LinearLayout.VERTICAL);
int pad = Ui.dp(this, 4);
grid.setPadding(pad, pad, pad, pad);
LinearLayout row = null;
for (int i = 0; i < 6; i++) {
if (i % 2 == 0) {
row = new LinearLayout(this);
grid.addView(row, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
}
ImageView tile = new ImageView(this);
tile.setScaleType(ImageView.ScaleType.FIT_CENTER);
tile.setPadding(pad, pad, pad, pad);
if (i < 5) {
final int n = i;
tile.setImageDrawable(Ui.button(this, NAMES[i], ART[i]));
tile.setContentDescription(LABELS[i]);
tile.setOnClickListener(v -> startActivity(
new Intent(this, ListActivity.class).putExtra(ListActivity.MODE, MODES[n])));
} else {
sixth = tile;
tile.setOnClickListener(v -> onSixth());
}
row.addView(tile, new LinearLayout.LayoutParams(0,
ViewGroup.LayoutParams.MATCH_PARENT, 1f));
}
sixthShowsNowPlaying = !hasTrack(); // force the first paint
setScreen(grid, false);
onPlaybackChanged();
}
private static boolean hasTrack() {
PlaybackService s = PlaybackService.get();
return s != null && s.current() != null;
}
@Override public void onPlaybackChanged() {
super.onPlaybackChanged();
if (sixth == null) return;
boolean now = hasTrack();
if (now == sixthShowsNowPlaying) return;
sixthShowsNowPlaying = now;
sixth.setImageDrawable(now ? Ui.button(this, "now_playing", R.drawable.now_playing)
: Ui.button(this, "shuffle_all", R.drawable.shuffle_all));
sixth.setContentDescription(now ? "Now Playing" : "Shuffle All");
}
/** "Now Playing" when there is something, else shuffle everything we know. */
private void onSixth() {
if (hasTrack()) {
openNowPlaying();
return;
}
Library.whenReady((lib, error) -> {
if (lib == null || lib.tracks.isEmpty()) {
Ui.toast(this, error != null ? error : "No songs synced yet");
return;
}
PlaybackService.play(this, lib.tracks, -1, true);
openNowPlaying();
});
}
// ---- first run ----
private boolean hasAccess() {
if (Build.VERSION.SDK_INT >= 30) return Environment.isExternalStorageManager();
return checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
}
private View setupView() {
LinearLayout box = new LinearLayout(this);
box.setOrientation(LinearLayout.VERTICAL);
box.setGravity(Gravity.CENTER);
int pad = Ui.dp(this, 28);
box.setPadding(pad, pad, pad, pad);
TextView msg = Ui.text(this, 26, false, Ui.BLACK);
msg.setSingleLine(false);
msg.setEllipsize(null);
msg.setText("andTunes plays the music LinTunes puts in Music/andTunes.\n\n"
+ "Android needs to let it read that folder: turn on "
+ "“Allow access to manage all files”.");
box.addView(msg);
TextView allow = Ui.text(this, 28, true, Ui.BLACK);
allow.setText("Allow access");
Ui.toggleStyle(this, allow, true);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 96));
lp.topMargin = Ui.dp(this, 32);
box.addView(allow, lp);
allow.setOnClickListener(v -> {
if (Build.VERSION.SDK_INT >= 30) {
startActivity(new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + getPackageName())));
} else {
requestPermissions(new String[] {Manifest.permission.READ_EXTERNAL_STORAGE}, 2);
}
});
return box;
}
@Override public void onRequestPermissionsResult(int code, String[] perms, int[] results) {
if (code == 2 && builtWithAccess != hasAccess()) build();
}
}
@@ -0,0 +1,207 @@
package me.teafry.andtunes;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.SeekBar;
import android.widget.TextView;
/**
* White background, square art, a slim scrubber, ◀ ⏯ ▶, then shuffle and
* repeat. <b>No back button</b> — Android's own is always on screen, and the
* panel is too small to spend on a second one (trav's call). The wheel stays
* volume here: this is the one screen where that's the useful thing.
*/
public class NowPlayingActivity extends BaseActivity {
private final Handler main = new Handler(Looper.getMainLooper());
private ImageView art, playPause;
private TextView title, detail, elapsed, remaining, shuffleBtn, repeatBtn;
private SeekBar scrubber;
private boolean dragging;
private Library.Track shown;
private Drawable playD, pauseD;
/** An ImageView that stays square inside whatever space it's given. */
private static final class Square extends ImageView {
Square(Context c) { super(c); }
@Override protected void onMeasure(int w, int h) {
int s = Math.min(android.view.View.MeasureSpec.getSize(w),
android.view.View.MeasureSpec.getSize(h));
setMeasuredDimension(s, s);
}
}
@Override protected void onCreate(Bundle state) {
super.onCreate(state);
int pad = Ui.dp(this, 16);
LinearLayout col = new LinearLayout(this);
col.setOrientation(LinearLayout.VERTICAL);
col.setBackgroundColor(Ui.WHITE);
col.setPadding(pad, pad, pad, Ui.dp(this, 8));
art = new Square(this);
art.setScaleType(ImageView.ScaleType.CENTER_CROP);
LinearLayout.LayoutParams alp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
alp.gravity = Gravity.CENTER_HORIZONTAL;
col.addView(art, alp);
title = Ui.text(this, 26, true, Ui.BLACK);
title.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams tlp = wrap();
tlp.topMargin = Ui.dp(this, 12);
col.addView(title, tlp);
detail = Ui.text(this, 20, false, Ui.DIM);
detail.setGravity(Gravity.CENTER);
LinearLayout.LayoutParams dlp = wrap();
dlp.topMargin = Ui.dp(this, 4);
col.addView(detail, dlp);
scrubber = new SeekBar(this);
ColorStateList black = ColorStateList.valueOf(Ui.BLACK);
scrubber.setProgressTintList(black);
scrubber.setThumbTintList(black);
scrubber.setProgressBackgroundTintList(ColorStateList.valueOf(0xFF9A9A9A));
scrubber.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override public void onProgressChanged(SeekBar b, int p, boolean fromUser) {
if (fromUser) showTimes(p, b.getMax());
}
@Override public void onStartTrackingTouch(SeekBar b) { dragging = true; }
@Override public void onStopTrackingTouch(SeekBar b) {
dragging = false;
PlaybackService s = PlaybackService.get();
if (s != null) s.seekTo(b.getProgress());
}
});
LinearLayout.LayoutParams slp = wrap();
slp.topMargin = Ui.dp(this, 8);
col.addView(scrubber, slp);
LinearLayout times = new LinearLayout(this);
elapsed = Ui.text(this, 18, false, Ui.BLACK);
remaining = Ui.text(this, 18, false, Ui.BLACK);
remaining.setGravity(Gravity.END);
times.addView(elapsed, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
times.addView(remaining, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
col.addView(times, wrap());
playD = Ui.button(this, "play", R.drawable.play);
pauseD = Ui.button(this, "pause", R.drawable.pause);
LinearLayout transport = new LinearLayout(this);
ImageView prev = transportButton(transport, Ui.button(this, "prev", R.drawable.prev));
playPause = transportButton(transport, playD);
ImageView next = transportButton(transport, Ui.button(this, "next", R.drawable.next));
prev.setContentDescription("Previous");
next.setContentDescription("Next");
prev.setOnClickListener(v -> withService(PlaybackService::previous));
playPause.setOnClickListener(v -> withService(PlaybackService::toggle));
next.setOnClickListener(v -> withService(PlaybackService::next));
col.addView(transport, new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 96)));
LinearLayout toggles = new LinearLayout(this);
shuffleBtn = toggle(toggles, "Shuffle");
repeatBtn = toggle(toggles, "Repeat");
shuffleBtn.setOnClickListener(v -> withService(s -> s.setShuffle(!s.shuffle())));
repeatBtn.setOnClickListener(v -> withService(PlaybackService::cycleRepeat));
LinearLayout.LayoutParams glp = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, Ui.dp(this, 64));
glp.topMargin = Ui.dp(this, 8);
col.addView(toggles, glp);
setScreen(col, false);
}
private LinearLayout.LayoutParams wrap() {
return new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
private ImageView transportButton(LinearLayout row, Drawable d) {
ImageView b = new ImageView(this);
b.setImageDrawable(d);
b.setScaleType(ImageView.ScaleType.FIT_CENTER);
int p = Ui.dp(this, 12);
b.setPadding(p, p, p, p);
row.addView(b, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
return b;
}
private TextView toggle(LinearLayout row, String label) {
TextView t = Ui.text(this, 22, true, Ui.BLACK);
t.setText(label);
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
int m = Ui.dp(this, 6);
lp.setMargins(m, 0, m, 0);
row.addView(t, lp);
return t;
}
private interface Action { void run(PlaybackService s); }
private void withService(Action a) {
PlaybackService s = PlaybackService.get();
if (s != null) a.run(s);
}
// ---- keeping it current ----
private final Runnable ticker = new Runnable() {
@Override public void run() {
PlaybackService s = PlaybackService.get();
if (s != null && !dragging) {
int dur = s.duration();
scrubber.setMax(Math.max(dur, 1));
scrubber.setProgress(s.position());
showTimes(s.position(), dur);
}
main.postDelayed(this, 500);
}
};
@Override protected void onResume() {
super.onResume();
main.post(ticker);
}
@Override protected void onPause() {
main.removeCallbacks(ticker);
super.onPause();
}
private void showTimes(int pos, int dur) {
elapsed.setText(Ui.time(pos));
remaining.setText("-" + Ui.time(Math.max(dur - pos, 0)));
}
@Override public void onPlaybackChanged() {
PlaybackService s = PlaybackService.get();
Library.Track t = s != null ? s.current() : null;
if (t == null) {
finish(); // nothing to show; back to wherever we came from
return;
}
if (t != shown) {
shown = t;
title.setText(t.title);
String album = t.album.isEmpty() ? "" : " — " + t.album;
detail.setText(t.artistLabel() + album);
Ui.art(art, t.art, 480);
}
playPause.setImageDrawable(s.isPlaying() ? pauseD : playD);
playPause.setContentDescription(s.isPlaying() ? "Pause" : "Play");
Ui.toggleStyle(this, shuffleBtn, s.shuffle());
int r = s.repeat();
repeatBtn.setText(r == PlaybackService.REPEAT_ONE ? "Repeat one"
: r == PlaybackService.REPEAT_ALL ? "Repeat all" : "Repeat");
Ui.toggleStyle(this, repeatBtn, r != PlaybackService.REPEAT_OFF);
}
}
@@ -0,0 +1,581 @@
package me.teafry.andtunes;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.graphics.drawable.Icon;
import android.media.AudioAttributes;
import android.media.AudioFocusRequest;
import android.media.AudioManager;
import android.media.MediaMetadata;
import android.media.MediaPlayer;
import android.media.session.MediaSession;
import android.media.session.PlaybackState;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.PowerManager;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
/**
* The queue and the one MediaPlayer, as a foreground service so music keeps
* going with the screen off.
*
* <p>Activities talk to it directly ({@link #get()}) — it's the same process,
* and a binder would only add ceremony. A new queue arrives through a static
* pending request plus {@code startForegroundService}, because the service may
* not exist yet.
*
* <p>Rules trav set: the end of a queue <b>pauses</b> (it never rolls into
* shuffle-all), and repeat is off / all / one.
*/
public class PlaybackService extends Service implements
MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener,
MediaPlayer.OnErrorListener, AudioManager.OnAudioFocusChangeListener {
interface Listener { void onPlaybackChanged(); }
static final int REPEAT_OFF = 0, REPEAT_ALL = 1, REPEAT_ONE = 2;
private static final String ACTION_START = "start", ACTION_RESTORE = "restore",
ACTION_TOGGLE = "toggle", ACTION_NEXT = "next", ACTION_PREV = "prev";
private static final String CHANNEL = "playback";
private static final int NOTIFICATION_ID = 1;
private static final int RESTART_WINDOW_MS = 3000;
private static PlaybackService sService;
private static final ArrayList<Listener> sListeners = new ArrayList<>();
private static ArrayList<Library.Track> sPendingQueue;
private static int sPendingIndex;
private static Boolean sPendingShuffle;
private static boolean sRestoreTried;
private final Handler main = new Handler(Looper.getMainLooper());
private final Random random = new Random();
private MediaPlayer mp;
private MediaSession session;
private AudioManager audio;
private AudioFocusRequest focusRequest;
private NotificationManager notifications;
private ArrayList<Library.Track> queue = new ArrayList<>();
private int[] order = new int[0]; // play order: indices into queue
private int pos = -1; // index into order
private boolean shuffle;
private int repeat = REPEAT_OFF;
private boolean prepared, playing, playWhenReady, resumeOnFocus, foreground;
private int pendingSeek;
private int failures;
// ---- the static front door ----
static PlaybackService get() { return sService; }
static void addListener(Listener l) { if (!sListeners.contains(l)) sListeners.add(l); }
static void removeListener(Listener l) { sListeners.remove(l); }
/**
* Play {@code tracks} from {@code index} (-1 = a random start). {@code
* shuffle} null keeps whatever shuffle mode the user left on.
*/
static void play(Context c, List<Library.Track> tracks, int index, Boolean shuffle) {
if (tracks.isEmpty()) return;
ArrayList<Library.Track> copy = new ArrayList<>(tracks);
if (sService != null) {
sService.setQueue(copy, index, shuffle);
return;
}
sPendingQueue = copy;
sPendingIndex = index;
sPendingShuffle = shuffle;
c.startForegroundService(new Intent(c, PlaybackService.class).setAction(ACTION_START));
}
/** Once per process: bring back the last queue, paused where it was left. */
static void restore(Context c) {
if (sRestoreTried || sService != null) return;
sRestoreTried = true;
if (prefs(c).getString("queue", "").isEmpty()) return;
c.startService(new Intent(c, PlaybackService.class).setAction(ACTION_RESTORE));
}
// ---- lifecycle ----
@Override public void onCreate() {
super.onCreate();
sService = this;
sRestoreTried = true;
audio = getSystemService(AudioManager.class);
notifications = getSystemService(NotificationManager.class);
notifications.createNotificationChannel(new NotificationChannel(
CHANNEL, "Playback", NotificationManager.IMPORTANCE_LOW));
AudioAttributes attrs = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build();
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attrs).setOnAudioFocusChangeListener(this).build();
mp = new MediaPlayer();
mp.setAudioAttributes(attrs);
mp.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
session = new MediaSession(this, "andTunes");
session.setCallback(new MediaSession.Callback() {
@Override public void onPlay() { play(); }
@Override public void onPause() { pause(); }
@Override public void onStop() { pause(); }
@Override public void onSkipToNext() { next(); }
@Override public void onSkipToPrevious() { previous(); }
@Override public void onSeekTo(long ms) { seekTo((int) ms); }
});
session.setActive(true);
SharedPreferences p = prefs(this);
shuffle = p.getBoolean("shuffle", false);
repeat = p.getInt("repeat", REPEAT_OFF);
}
@Override public int onStartCommand(Intent intent, int flags, int startId) {
String action = intent != null ? intent.getAction() : null;
if (ACTION_START.equals(action) && sPendingQueue != null) {
// startForegroundService() gives us five seconds to say so.
ArrayList<Library.Track> q = sPendingQueue;
sPendingQueue = null;
setQueue(q, sPendingIndex, sPendingShuffle);
} else if (ACTION_RESTORE.equals(action)) {
restoreState();
} else if (ACTION_TOGGLE.equals(action)) {
toggle();
} else if (ACTION_NEXT.equals(action)) {
next();
} else if (ACTION_PREV.equals(action)) {
previous();
}
if (ACTION_START.equals(action) && !foreground) goForeground();
return START_NOT_STICKY;
}
@Override public void onTaskRemoved(Intent rootIntent) {
if (!playing) stopSelf();
}
@Override public void onDestroy() {
saveState();
abandonFocus();
unregisterNoisy();
main.removeCallbacksAndMessages(null);
session.release();
mp.release();
notifications.cancel(NOTIFICATION_ID);
sService = null;
changed();
super.onDestroy();
}
@Override public IBinder onBind(Intent intent) { return null; }
// ---- what the screens read ----
Library.Track current() {
return pos >= 0 && pos < order.length ? queue.get(order[pos]) : null;
}
boolean isPlaying() { return playing; }
boolean shuffle() { return shuffle; }
int repeat() { return repeat; }
int position() {
if (!prepared) return pendingSeek;
try { return mp.getCurrentPosition(); } catch (IllegalStateException e) { return 0; }
}
int duration() {
if (prepared) {
try { return mp.getDuration(); } catch (IllegalStateException ignored) { }
}
Library.Track t = current();
return t != null ? t.secs * 1000 : 0;
}
// ---- transport ----
void toggle() { if (playing) pause(); else play(); }
void play() {
if (current() == null) return;
if (!prepared) { playWhenReady = true; return; }
if (audio.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
return;
}
mp.setVolume(1f, 1f);
mp.start();
playing = true;
registerNoisy();
goForeground();
tick();
changed();
}
void pause() {
playWhenReady = false;
if (playing) {
try { mp.pause(); } catch (IllegalStateException ignored) { }
}
playing = false;
unregisterNoisy();
if (foreground) {
// Keep the notification (and the session) around while paused, but
// let the system reclaim the service if it has to — state is saved.
stopForeground(false);
foreground = false;
}
saveState();
changed();
}
void seekTo(int ms) {
if (prepared) mp.seekTo(ms);
else pendingSeek = ms;
changed();
}
void next() { advance(false); }
void previous() {
if (position() > RESTART_WINDOW_MS || pos <= 0) {
seekTo(0);
return;
}
load(pos - 1, playing, 0);
}
void setShuffle(boolean on) {
shuffle = on;
Library.Track cur = current();
int curIndex = cur != null ? order[pos] : -1;
buildOrder(curIndex);
saveState();
changed();
}
void cycleRepeat() {
repeat = (repeat + 1) % 3;
saveState();
changed();
}
// ---- queue ----
private void setQueue(ArrayList<Library.Track> tracks, int index, Boolean shuffleMode) {
queue = tracks;
if (shuffleMode != null) shuffle = shuffleMode;
int start = index >= 0 && index < tracks.size() ? index : random.nextInt(tracks.size());
if (shuffleMode == null && index < 0) start = 0;
buildOrder(start);
failures = 0;
load(pos, true, 0);
}
/** Rebuild the play order so {@code keepIndex} (a queue index) is current. */
private void buildOrder(int keepIndex) {
int n = queue.size();
order = new int[n];
for (int i = 0; i < n; i++) order[i] = i;
if (shuffle) {
for (int i = n - 1; i > 0; i--) {
int j = random.nextInt(i + 1);
int tmp = order[i]; order[i] = order[j]; order[j] = tmp;
}
if (keepIndex >= 0) {
for (int i = 0; i < n; i++) {
if (order[i] == keepIndex) { order[i] = order[0]; order[0] = keepIndex; break; }
}
pos = 0;
} else {
pos = n > 0 ? 0 : -1;
}
} else {
pos = keepIndex >= 0 ? keepIndex : (n > 0 ? 0 : -1);
}
}
/** Step forward. {@code auto} = the song finished by itself. */
private void advance(boolean auto) {
if (order.length == 0) return;
if (auto && repeat == REPEAT_ONE) {
load(pos, true, 0);
return;
}
int n = pos + 1;
if (n >= order.length) {
if (repeat != REPEAT_ALL) {
if (auto) {
// End of the queue pauses, parked at the top of the last song.
playing = false;
pendingSeek = 0;
if (prepared) mp.seekTo(0);
pause();
}
return;
}
if (shuffle) buildOrder(-1);
n = 0;
}
load(n, auto || playing, 0);
}
private void load(int newPos, boolean play, int seekMs) {
pos = newPos;
Library.Track t = current();
if (t == null) return;
prepared = false;
playWhenReady = play;
pendingSeek = seekMs;
try {
mp.reset();
if (!t.file().isFile()) throw new java.io.FileNotFoundException(t.path);
mp.setDataSource(t.file().getPath());
mp.prepareAsync();
} catch (Exception e) {
skipBroken(t, e instanceof java.io.FileNotFoundException ? "missing" : "can't play");
return;
}
saveState();
changed();
}
/** A synced-then-deleted file, or one MediaPlayer chokes on: skip, don't stop. */
private void skipBroken(Library.Track t, String why) {
Ui.toast(this, "Skipped “" + t.title + "” (" + why + ")");
if (++failures >= order.length) {
failures = 0;
playing = false;
playWhenReady = false;
changed();
return;
}
boolean wasPlaying = playWhenReady;
if (pos + 1 < order.length) load(pos + 1, wasPlaying, 0);
else if (repeat == REPEAT_ALL) load(0, wasPlaying, 0);
else { playing = false; changed(); }
}
@Override public void onPrepared(MediaPlayer player) {
prepared = true;
failures = 0;
if (pendingSeek > 0) mp.seekTo(pendingSeek);
pendingSeek = 0;
if (playWhenReady) play();
else changed();
}
@Override public void onCompletion(MediaPlayer player) {
if (prepared) advance(true);
}
@Override public boolean onError(MediaPlayer player, int what, int extra) {
Library.Track t = current();
prepared = false;
if (t != null) skipBroken(t, "can't play");
return true;
}
// ---- audio focus, noisy ----
@Override public void onAudioFocusChange(int change) {
switch (change) {
case AudioManager.AUDIOFOCUS_LOSS:
resumeOnFocus = false;
pause();
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT:
resumeOnFocus = playing;
pause();
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
if (playing) mp.setVolume(0.2f, 0.2f);
break;
case AudioManager.AUDIOFOCUS_GAIN:
mp.setVolume(1f, 1f);
if (resumeOnFocus) { resumeOnFocus = false; play(); }
break;
}
}
private void abandonFocus() { audio.abandonAudioFocusRequest(focusRequest); }
private final BroadcastReceiver noisy = new BroadcastReceiver() {
@Override public void onReceive(Context c, Intent i) { pause(); }
};
private boolean noisyRegistered;
private void registerNoisy() {
if (noisyRegistered) return;
registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
noisyRegistered = true;
}
private void unregisterNoisy() {
if (!noisyRegistered) return;
unregisterReceiver(noisy);
noisyRegistered = false;
}
// ---- session, notification, listeners ----
private final Runnable ticker = this::tick;
/** While playing: keep the session position honest and save now and then. */
private void tick() {
main.removeCallbacks(ticker);
if (!playing) return;
saveState();
main.postDelayed(ticker, 15000);
}
private void changed() {
if (sService == this) updateSession();
for (Listener l : new ArrayList<>(sListeners)) l.onPlaybackChanged();
}
private Library.Track shownInSession;
private void updateSession() {
Library.Track t = current();
long actions = PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE
| PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_SKIP_TO_NEXT
| PlaybackState.ACTION_SKIP_TO_PREVIOUS | PlaybackState.ACTION_SEEK_TO
| PlaybackState.ACTION_STOP;
session.setPlaybackState(new PlaybackState.Builder().setActions(actions)
.setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,
position(), playing ? 1f : 0f).build());
if (t == null) return;
if (t != shownInSession) {
shownInSession = t;
session.setMetadata(new MediaMetadata.Builder()
.putString(MediaMetadata.METADATA_KEY_TITLE, t.title)
.putString(MediaMetadata.METADATA_KEY_ARTIST, t.artistLabel())
.putString(MediaMetadata.METADATA_KEY_ALBUM, t.album)
.putLong(MediaMetadata.METADATA_KEY_DURATION, t.secs * 1000L)
.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, Ui.artNow(t.art, 240))
.build());
}
notifications.notify(NOTIFICATION_ID, buildNotification());
}
private void goForeground() {
startForeground(NOTIFICATION_ID, buildNotification());
foreground = true;
}
private Notification buildNotification() {
Library.Track t = current();
Intent open = new Intent(this, MenuActivity.class)
.setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
Notification.Builder b = new Notification.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_notify)
.setContentTitle(t != null ? t.title : "andTunes")
.setContentText(t != null ? t.artistLabel() : "")
.setContentIntent(PendingIntent.getActivity(this, 0, open,
PendingIntent.FLAG_IMMUTABLE))
.setVisibility(Notification.VISIBILITY_PUBLIC)
.setOngoing(playing)
.setShowWhen(false)
.addAction(action(R.drawable.prev, "Previous", ACTION_PREV, 1))
.addAction(action(playing ? R.drawable.pause : R.drawable.play,
playing ? "Pause" : "Play", ACTION_TOGGLE, 2))
.addAction(action(R.drawable.next, "Next", ACTION_NEXT, 3))
.setStyle(new Notification.MediaStyle()
.setMediaSession(session.getSessionToken())
.setShowActionsInCompactView(0, 1, 2));
if (t != null) b.setLargeIcon(Ui.artNow(t.art, 240));
return b.build();
}
private Notification.Action action(int icon, String label, String act, int code) {
PendingIntent pi = PendingIntent.getService(this, code,
new Intent(this, PlaybackService.class).setAction(act),
PendingIntent.FLAG_IMMUTABLE);
return new Notification.Action.Builder(Icon.createWithResource(this, icon), label, pi)
.build();
}
// ---- remembering where we were ----
private static SharedPreferences prefs(Context c) {
return c.getSharedPreferences("playback", MODE_PRIVATE);
}
private void saveState() {
StringBuilder ids = new StringBuilder(), ord = new StringBuilder();
for (Library.Track t : queue) ids.append(t.id).append(',');
for (int i : order) ord.append(i).append(',');
prefs(this).edit()
.putString("queue", ids.toString())
.putString("order", ord.toString())
.putInt("pos", pos)
.putInt("ms", current() != null ? position() : 0)
.putBoolean("shuffle", shuffle)
.putInt("repeat", repeat)
.apply();
}
private void restoreState() {
Library.whenReady((lib, error) -> {
if (lib == null || current() != null) return;
SharedPreferences p = prefs(this);
ArrayList<Library.Track> q = new ArrayList<>();
ArrayList<Integer> kept = new ArrayList<>(); // old index -> still present
String[] ids = p.getString("queue", "").split(",");
for (int i = 0; i < ids.length; i++) {
if (ids[i].isEmpty()) continue;
Library.Track t = lib.byId.get(Long.parseLong(ids[i]));
if (t != null) { q.add(t); kept.add(i); }
}
if (q.isEmpty()) { stopSelf(); return; }
queue = q;
int[] saved = parseInts(p.getString("order", ""));
int savedPos = p.getInt("pos", 0);
if (kept.size() == ids.length - (ids.length > 0 && ids[ids.length - 1].isEmpty() ? 1 : 0)
&& saved.length == q.size() && savedPos >= 0 && savedPos < saved.length) {
order = saved;
pos = savedPos;
} else {
// The library changed under the saved queue; keep it, lose the spot.
buildOrder(0);
}
load(pos, false, p.getInt("ms", 0));
});
}
private static int[] parseInts(String csv) {
ArrayList<Integer> out = new ArrayList<>();
for (String s : csv.split(",")) {
if (s.isEmpty()) continue;
try { out.add(Integer.parseInt(s)); } catch (NumberFormatException ignored) { }
}
int[] a = new int[out.size()];
for (int i = 0; i < a.length; i++) a[i] = out.get(i);
return a;
}
}
@@ -0,0 +1,152 @@
package me.teafry.andtunes;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.os.Handler;
import android.os.Looper;
import android.text.TextUtils;
import android.util.LruCache;
import android.util.TypedValue;
import android.view.Gravity;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import java.io.File;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Sizes, colours and the two things every screen loads from the device:
* button artwork and album covers.
*
* <p>The R1 runs at an override density of 160 on a 2.88" panel, so one dp is
* about half its usual physical size — every size in here is roughly double
* what a phone app would use. Don't "fix" that by touching the density.
*/
final class Ui {
static final int BLACK = 0xFF000000;
static final int WHITE = 0xFFFFFFFF;
static final int DIM = 0xFF3A3A3A; // secondary text, still high contrast
static final int PLACEHOLDER = 0xFFE6E6E6;
static final int ROW_DP = 88;
static final float TITLE_SP = 28;
static final float SUB_SP = 21;
private static final Handler MAIN = new Handler(Looper.getMainLooper());
private static final ExecutorService ART = Executors.newFixedThreadPool(2);
private static final LruCache<String, Bitmap> CACHE =
new LruCache<String, Bitmap>(12 * 1024 * 1024) {
@Override protected int sizeOf(String k, Bitmap b) { return b.getByteCount(); }
};
private Ui() {}
static int dp(Context c, float v) {
return Math.round(v * c.getResources().getDisplayMetrics().density);
}
static TextView text(Context c, float sp, boolean bold, int color) {
TextView t = new TextView(c);
t.setTextSize(TypedValue.COMPLEX_UNIT_SP, sp);
t.setTextColor(color);
t.setSingleLine(true);
t.setEllipsize(TextUtils.TruncateAt.END);
t.setIncludeFontPadding(false);
if (bold) t.setTypeface(Typeface.DEFAULT_BOLD);
return t;
}
/** A black-outlined pill; filled black when {@code on}. */
static void toggleStyle(Context c, TextView t, boolean on) {
GradientDrawable d = new GradientDrawable();
d.setCornerRadius(dp(c, 12));
d.setStroke(dp(c, 3), BLACK);
d.setColor(on ? BLACK : WHITE);
t.setBackground(d);
t.setTextColor(on ? WHITE : BLACK);
t.setGravity(Gravity.CENTER);
}
static void toast(Context c, String msg) {
Toast.makeText(c.getApplicationContext(), msg, Toast.LENGTH_SHORT).show();
}
static String time(int ms) {
int s = Math.max(ms, 0) / 1000;
return (s / 60) + ":" + (s % 60 < 10 ? "0" : "") + (s % 60);
}
/**
* {@code Buttons/<name>.png} from the device when the user put one there,
* else the bundled default. LinTunes only ever fills that folder in where
* a file is missing, so what's there is either ours or theirs — theirs wins.
*/
static Drawable button(Context c, String name, int fallback) {
File custom = new File(Library.root(), "Buttons/" + name + ".png");
if (custom.isFile()) {
Bitmap b = BitmapFactory.decodeFile(custom.getPath());
if (b != null) return new android.graphics.drawable.BitmapDrawable(c.getResources(), b);
}
return c.getDrawable(fallback);
}
/**
* Load {@code Art/<key>.jpg} into {@code view}, decoded off the main thread
* at roughly {@code px} wide. Recycled list rows are handled by tagging the
* view with what it should show and dropping results that arrive late.
*/
static void art(ImageView view, String artRel, int px) {
if (artRel == null || artRel.isEmpty()) {
view.setTag(null);
view.setImageDrawable(null);
view.setBackgroundColor(PLACEHOLDER);
return;
}
final String key = artRel + "@" + px;
view.setTag(key);
Bitmap hit = CACHE.get(key);
if (hit != null) {
view.setImageBitmap(hit);
return;
}
view.setImageDrawable(null);
view.setBackgroundColor(PLACEHOLDER);
ART.execute(() -> {
Bitmap b = decode(new File(Library.root(), artRel), px);
if (b != null) CACHE.put(key, b);
MAIN.post(() -> {
if (key.equals(view.getTag()) && b != null) view.setImageBitmap(b);
});
});
}
private static Bitmap decode(File f, int px) {
BitmapFactory.Options o = new BitmapFactory.Options();
o.inJustDecodeBounds = true;
BitmapFactory.decodeFile(f.getPath(), o);
if (o.outWidth <= 0) return null;
int sample = 1;
while (o.outWidth / (sample * 2) >= px) sample *= 2;
o = new BitmapFactory.Options();
o.inSampleSize = sample;
return BitmapFactory.decodeFile(f.getPath(), o);
}
/** Synchronous, for the notification and lock screen; small and cached. */
static Bitmap artNow(String artRel, int px) {
if (artRel == null || artRel.isEmpty()) return null;
String key = artRel + "@" + px;
Bitmap b = CACHE.get(key);
if (b == null) {
b = decode(new File(Library.root(), artRel), px);
if (b != null) CACHE.put(key, b);
}
return b;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 772 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 748 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Black on white everywhere: the R1's panel is dim, and full contrast
is the only thing that reads on it. The window background is what
paints before the first frame, so it is white too. -->
<style name="AndTunes" parent="@android:style/Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/white</item>
<item name="android:colorBackground">@android:color/white</item>
<item name="android:textColorPrimary">@android:color/black</item>
<item name="android:colorAccent">@android:color/black</item>
<item name="android:colorControlActivated">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowAnimationStyle">@null</item>
</style>
</resources>