commit def581b9a858a6faf0754c86ea7baafc5c214a16
| author | 斟酌 鵬兄 <tgckpg@gmail.com> |
| date | 2026-10-18T03:24:49Z |
| subject | Added bluetooth server / client mode |
commit def581b9a858a6faf0754c86ea7baafc5c214a16
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date: 2026-10-18T03:24:49Z
Added bluetooth server / client mode
---
README.md | 57 +++-
VALIDATION.md | 25 ++
app/src/main/AndroidManifest.xml | 6 +-
.../net/astropenguin/loopdeck/BluetoothLink.java | 317 +++++++++++++++++++++
.../net/astropenguin/loopdeck/MainActivity.java | 244 ++++++++--------
.../net/astropenguin/loopdeck/MusicLibrary.java | 113 ++++++++
.../net/astropenguin/loopdeck/PlaybackService.java | 70 +++--
.../java/net/astropenguin/loopdeck/RemoteWire.java | 22 ++
scripts/test-bluetooth.py | 162 +++++++++++
scripts/test-playback-handoff.py | 5 +-
10 files changed, 884 insertions(+), 137 deletions(-)
diff --git a/README.md b/README.md
index b0fb072..cdbbfbe 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# LoopDeck 0.1
-A small, offline Android music player for the FiiO M11S. Native Java Views,
+A small, offline Android music player and Bluetooth remote for the FiiO M11S. Native Java Views,
FFmpeg file parsing and decoding, a native TAK 1.0 decoder, AudioTrack output,
and A–B repeat.
@@ -13,7 +13,7 @@ and A–B repeat.
- Hold **Set A** or **Set B** to enter exact marker times in milliseconds.
- Background playback, media notification, lock-screen controls and standard media keys.
- Pause when headphones are disconnected; handle Android audio focus.
-- No full-library scan, artwork extraction, network access, account, or analytics.
+- Optional Bluetooth server/client control; no full-library scan, artwork extraction, Internet access, account, or analytics.
The browser shows every file so unusual extensions can still be selected.
Automatic next/previous includes recognized audio filenames and audio MIME types.
@@ -212,3 +212,56 @@ java -m jdk.compiler/com.sun.tools.javac.Main -d /tmp/loopdeck-tests \
scripts/GaplessTest.java
java -cp /tmp/loopdeck-tests net.astropenguin.loopdeck.GaplessTest
```
+
+## Bluetooth server / client
+
+Install the same APK on the player and phone. Music stays on the player: Bluetooth
+carries commands, folder names and playback state, with no audio streaming or file
+transfer. The player still decodes locally, requests the file's sample rate and
+runs the A–B loop itself.
+
+1. Pair the devices in **Android Settings → Bluetooth**. Finish pairing there,
+ then leave the Settings scan screen before connecting in LoopDeck.
+2. On the FiiO, choose **Music folder**, then **Bluetooth → Server**.
+3. On the Pixel, choose **Bluetooth → Client**, grant **Nearby devices** if
+ prompted, and choose the paired FiiO. The phone needs no music-folder grant.
+4. Browse the remote folders and album/CUE track lists using the normal list and
+ up arrow. **Remote root** returns to the shared music folder. Folders are
+ transferred in pages of 50; **Find / refresh** filters names in the current
+ folder or reloads the root. A blank filter shows everything in the folder.
+5. Play/pause, stop, previous/next, seek and all A–B controls operate on the FiiO,
+ including holding Set A/B for exact milliseconds. Marker taps use the player's
+ position when the command arrives; numeric editing avoids radio timing error.
+6. **Local player / Bluetooth off**, or **Bluetooth off** in the notification,
+ closes the link. Stopping music keeps Server running. Disconnecting the phone
+ leaves the FiiO playing and waiting for another connection. To reconnect, pick
+ **Client** and the player again. Switching the phone into Client pauses its
+ local playback; it does not resume automatically when leaving Client.
+
+The active mode lives in the foreground playback service, so leaving/rotating the
+screen keeps the connection. It is intentionally not auto-started after process
+or device restart. Both ends expose media-notification controls. Screen-off and
+battery-management behavior must be checked on the actual firmware.
+
+This uses one secure, bonded Bluetooth Classic RFCOMM peer, a dedicated service
+UUID and a versioned, length-prefixed JSON protocol. No discovery, BLE advertising,
+location access, Wi-Fi, TCP listener or new app dependencies are used. Pairing
+provides trust: any paired device running the matching client can control an
+enabled Server. Only the selected folder is exposed, using server-issued item
+indices rather than accepting arbitrary paths/URIs. The phone caches only the
+current page in memory. Messages are capped at 64 KiB and the outbound queue at
+32 messages; playback state is sent at most twice per second, only when changed.
+Audio decoding and Bluetooth/file IO run on separate workers.
+
+Host integration test (JDK 17+, test-only `org.json` jar):
+
+```sh
+curl -fL -o /tmp/loopdeck-json.jar \
+ https://repo.maven.apache.org/maven2/org/json/json/20240303/json-20240303.jar
+python3 scripts/test-bluetooth.py /tmp/loopdeck-json.jar
+python3 scripts/test-playback-handoff.py
+```
+
+The Bluetooth test runs the real protocol and shared folder/CUE code over fake
+RFCOMM backed by loopback TCP. It does not test Android permission prompts,
+Bluetooth hardware, radio coexistence or vendor power management.
diff --git a/VALIDATION.md b/VALIDATION.md
index a39a939..ae7d8a2 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -149,3 +149,28 @@ uses its existing queued audio without an added lead-in.
- MainActivity and PlaybackService passed Java syntax parsing; diff checks passed.
No APK was built. Android/vendor processing may still change the rate downstream.
+
+## Bluetooth server/client (base 4cbe5c5)
+
+- All application Java sources compiled with JDK 17 against the official Android
+ API 35 `android.jar`. Deprecation warnings only. Manifest XML parsed and
+ `git diff --check` passed.
+- `test-bluetooth.py` passed using the actual `BluetoothLink`, `RemoteWire`,
+ `MusicLibrary` and CUE parser with fake Android adapters/storage and sockets
+ backed by loopback TCP. Covered Unicode/framing boundaries, invalid/truncated
+ input, protocol negotiation, 50-entry pages, folder-name filtering, full-folder
+ queues across pages, CUE selection, up/root navigation, rejection of stale
+ selections, all playback/marker commands, invalid times, disconnect/reconnect,
+ and rejection/recovery of an incompatible peer.
+- The actual PlaybackService host simulation passed. Added checks that all client
+ controls forward over Bluetooth without adding local playback work; Stop keeps
+ Server active and disabling Bluetooth after Stop lets the service stop.
+ Existing 96 kHz/fallback, startup silence, gapless sample order, audible metadata
+ and decoder cleanup checks still pass.
+
+No APK was built. On the FiiO/Pixel, verify initial Bluetooth pairing, permission
+prompts, folder grant, screen-off controls, media notifications, CUE browsing,
+A–B editing and native-rate output. Disconnect/reconnect, disable Bluetooth in
+Settings and switch modes while playing. Server playback should continue when the
+phone disconnects, and Client must never play the remote file locally. Hardware
+Bluetooth throughput, permissions and vendor background behavior remain unverified.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 67a23d0..e3e3d09 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
+ <!-- Legacy declarations stay uncapped while targetSdk is 29. No discovery/scan. -->
+ <uses-permission android:name="android.permission.BLUETOOTH" />
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+ <uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<application android:label="LoopDeck" android:theme="@style/AppTheme" android:allowBackup="false" android:icon="@drawable/ic_music">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
@@ -9,6 +13,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
- <service android:name=".PlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback" />
+ <service android:name=".PlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback|connectedDevice" />
</application>
</manifest>
diff --git a/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java b/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
new file mode 100644
index 0000000..4759dd4
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
@@ -0,0 +1,317 @@
+package net.astropenguin.loopdeck;
+
+import android.bluetooth.*;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.DocumentsContract;
+import org.json.*;
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import net.astropenguin.loopdeck.MusicLibrary.Entry;
+
+/** One secure RFCOMM peer. File IO and socket IO never run on the playback/UI worker. */
+final class BluetoothLink implements AutoCloseable {
+ private static final UUID UUID_SERVICE=UUID.fromString("aca23821-cd14-47c2-9d9f-c821ef5c3aa0");
+ static final int LOCAL=0, SERVER=1, CLIENT=2, PAGE_SIZE=50;
+ private final PlaybackService player;
+ private final Handler main=new Handler(Looper.getMainLooper());
+ private final BluetoothAdapter adapter;
+ private long epoch;
+ private BluetoothServerSocket listener;
+ private BluetoothSocket pending;
+ private Peer peer;
+ volatile int mode=LOCAL;
+ volatile boolean connected;
+ volatile String status="Bluetooth off", error="";
+ volatile PlaybackService.Snapshot remote=emptySnapshot();
+ volatile Listing listing=new Listing(0,"Connect to a player",false,0,0,Collections.emptyList());
+
+ static final class Item {
+ final int id; final String name; final boolean folder;
+ Item(int id,String name,boolean folder) { this.id=id; this.name=name; this.folder=folder; }
+ }
+ static final class Listing {
+ final long version; final String title; final boolean up; final int offset,total; final List<Item> items;
+ Listing(long version,String title,boolean up,int offset,int total,List<Item> items) {
+ this.version=version; this.title=title; this.up=up; this.offset=offset; this.total=total;
+ this.items=Collections.unmodifiableList(new ArrayList<>(items));
+ }
+ }
+ BluetoothLink(PlaybackService player) {
+ this.player=player;
+ BluetoothManager manager=(BluetoothManager)player.getSystemService(Context.BLUETOOTH_SERVICE);
+ adapter=manager==null ? null : manager.getAdapter();
+ }
+ private static PlaybackService.Snapshot emptySnapshot() {
+ return new PlaybackService.Snapshot("Remote player","","Not connected",0,0,-1,-1,false,false,false);
+ }
+ static JSONObject message(String type,Object... values) {
+ JSONObject result=new JSONObject();
+ try { result.put("type",type); for (int i=0;i<values.length;i+=2) result.put((String)values[i],values[i+1]); }
+ catch (JSONException e) { throw new IllegalArgumentException(e); }
+ return result;
+ }
+ synchronized void start(int requested,String address) {
+ close(); error="";
+ try {
+ if (adapter==null) throw new IOException("This device has no Bluetooth adapter");
+ if (!adapter.isEnabled()) throw new IOException("Enable Bluetooth in Android Settings first");
+ mode=requested; final long run=epoch;
+ remote=emptySnapshot(); listing=new Listing(0,"Connecting…",false,0,0,Collections.emptyList());
+ status=requested==SERVER ? "Server · waiting for a paired remote" : "Client · connecting…";
+ new Thread(() -> run(run,requested,address),"LoopDeck Bluetooth").start();
+ } catch (Exception e) { mode=LOCAL; status="Bluetooth off"; error=e.getMessage(); }
+ }
+ private synchronized boolean current(long run) { return run==epoch && mode!=LOCAL; }
+ private void run(long run,int role,String address) {
+ try {
+ if (role==SERVER) {
+ BluetoothServerSocket server=adapter.listenUsingRfcommWithServiceRecord("LoopDeck",UUID_SERVICE);
+ synchronized (this) { if (!current(run)) { server.close(); return; } listener=server; }
+ while (current(run)) {
+ BluetoothSocket socket=server.accept();
+ if (socket.getRemoteDevice().getBondState()!=BluetoothDevice.BOND_BONDED) { socket.close(); continue; }
+ serve(run,socket,role);
+ }
+ } else {
+ BluetoothDevice device=adapter.getRemoteDevice(address);
+ if (device.getBondState()!=BluetoothDevice.BOND_BONDED) throw new IOException("Pair the player in Android Bluetooth Settings first");
+ BluetoothSocket socket=device.createRfcommSocketToServiceRecord(UUID_SERVICE);
+ synchronized (this) { if (!current(run)) { socket.close(); return; } pending=socket; }
+ Runnable timeout=() -> { synchronized (BluetoothLink.this) {
+ if (current(run) && pending==socket) { error="Bluetooth connection timed out"; closeSocket(socket); }
+ } };
+ main.postDelayed(timeout,20000);
+ try { socket.connect(); } finally { main.removeCallbacks(timeout); }
+ serve(run,socket,role);
+ }
+ } catch (Exception e) {
+ synchronized (this) { if (current(run)) { status="Bluetooth disconnected"; if (error.isEmpty()) error=detail(e); } }
+ } finally {
+ synchronized (this) {
+ if (current(run)) {
+ closeSocket(pending); pending=null; closeListener(); connected=false;
+ if (role==CLIENT) listing=new Listing(0,"Disconnected · reconnect from Bluetooth",false,0,0,Collections.emptyList());
+ player.bluetoothChanged();
+ }
+ }
+ }
+ }
+ private void serve(long run,BluetoothSocket socket,int role) {
+ Peer active=new Peer(run,socket,role);
+ synchronized (this) {
+ if (!current(run)) { closeSocket(socket); return; }
+ pending=null; peer=active;
+ }
+ try { active.read(); }
+ catch (Exception | LinkageError e) {
+ synchronized (this) { if (current(run) && !(e instanceof EOFException)) error=detail(e); }
+ } finally {
+ active.close();
+ synchronized (this) {
+ if (peer==active) {
+ peer=null; connected=false; remote=emptySnapshot();
+ listing=new Listing(0,"Disconnected · reconnect from Bluetooth",false,0,0,Collections.emptyList());
+ status=role==SERVER ? "Server · waiting for a paired remote" : "Client · disconnected";
+ player.bluetoothChanged();
+ }
+ }
+ }
+ }
+ synchronized boolean send(JSONObject message) {
+ if (!connected || peer==null) return false;
+ if (!peer.out.offer(message.toString())) { error="Bluetooth is busy; reconnect"; peer.close(); return false; }
+ return true;
+ }
+ void command(String name,Object... args) {
+ error="";
+ JSONObject json=message(name,args);
+ if (!send(json)) error="Not connected to the player";
+ }
+ void open(Item item,Listing view) { command("open","version",view.version,"id",item.id); }
+ void page(int offset) { command("list","offset",offset); }
+ @Override public synchronized void close() {
+ ++epoch; mode=LOCAL; connected=false; status="Bluetooth off"; error="";
+ closeListener(); closeSocket(pending); pending=null;
+ if (peer!=null) { peer.close(); peer=null; }
+ remote=emptySnapshot(); listing=new Listing(0,"Connect to a player",false,0,0,Collections.emptyList());
+ }
+ private void closeListener() { if (listener!=null) try { listener.close(); } catch (IOException ignored) {} listener=null; }
+ private static void closeSocket(BluetoothSocket socket) { if (socket!=null) try { socket.close(); } catch (IOException ignored) {} }
+ private static String detail(Throwable e) { return e.getMessage()==null ? e.getClass().getSimpleName() : e.getMessage(); }
+
+ private final class Peer implements AutoCloseable {
+ final long run; final BluetoothSocket socket; final int role;
+ final ArrayBlockingQueue<String> out=new ArrayBlockingQueue<>(32);
+ volatile boolean alive=true,ready;
+ Thread writer;
+ ArrayList<Entry> entries=new ArrayList<>();
+ final ArrayList<String> parents=new ArrayList<>();
+ Uri tree; String directory,label,query=""; boolean tracks;
+ long version;
+ Peer(long run,BluetoothSocket socket,int role) { this.run=run; this.socket=socket; this.role=role; }
+ void emit(JSONObject value) throws IOException {
+ if (!alive || !current(run)) throw new IOException("Bluetooth disconnected");
+ if (!out.offer(value.toString())) throw new IOException("Bluetooth peer is too slow");
+ }
+ void read() throws Exception {
+ DataInputStream in=new DataInputStream(socket.getInputStream());
+ writer=new Thread(this::write,"LoopDeck Bluetooth output"); writer.start();
+ emit(message("hello","version",RemoteWire.VERSION,"role",role));
+ // Close peers which connect but never finish the protocol handshake.
+ Runnable timeout=() -> { if (!ready) close(); };
+ main.postDelayed(timeout,10000);
+ try {
+ JSONObject hello=new JSONObject(RemoteWire.read(in));
+ if (!"hello".equals(hello.getString("type")) || hello.getInt("version")!=RemoteWire.VERSION ||
+ hello.getInt("role")!=(role==SERVER ? CLIENT : SERVER)) throw new IOException("Incompatible LoopDeck Bluetooth peer");
+ synchronized (BluetoothLink.this) {
+ if (!current(run)) return;
+ ready=true; connected=true; error="";
+ status=(role==SERVER ? "Server · " : "Client · ")+socket.getRemoteDevice().getName();
+ player.bluetoothChanged();
+ }
+ if (role==CLIENT) emit(message("root"));
+ while (alive && current(run)) {
+ JSONObject request=new JSONObject(RemoteWire.read(in));
+ if (!alive || !current(run)) break;
+ if (role==CLIENT) { synchronized (BluetoothLink.this) { if (current(run) && peer==this) receive(request); } }
+ else {
+ try { handle(request); }
+ catch (Exception | LinkageError e) { emit(message("error","message",detail(e))); }
+ }
+ }
+ } finally { main.removeCallbacks(timeout); }
+ }
+ void write() {
+ String previous=""; long last=0;
+ try {
+ DataOutputStream output=new DataOutputStream(socket.getOutputStream());
+ while (alive && current(run)) {
+ String value=out.poll(250,TimeUnit.MILLISECONDS);
+ if (value!=null) RemoteWire.write(output,value);
+ long now=android.os.SystemClock.elapsedRealtime();
+ if (role==SERVER && ready && now-last>=500) {
+ String state=state(player.snapshot).toString();
+ if (!state.equals(previous)) { RemoteWire.write(output,state); previous=state; }
+ last=now;
+ }
+ }
+ } catch (Exception e) { if (alive && current(run)) error=detail(e); }
+ finally { close(); }
+ }
+ @Override public void close() { alive=false; closeSocket(socket); if (writer!=null) writer.interrupt(); }
+ void receive(JSONObject value) throws JSONException,IOException {
+ switch (value.getString("type")) {
+ case "state":
+ PlaybackService.Snapshot previous=remote;
+ remote=new PlaybackService.Snapshot(value.getString("title"),value.getString("format"),value.getString("status"),
+ value.getLong("position"),value.getLong("duration"),value.getLong("a"),value.getLong("b"),
+ value.getBoolean("playing"),value.getBoolean("loop"),value.getBoolean("loaded"));
+ if (!remote.title.equals(previous.title) || remote.playing!=previous.playing || remote.loaded!=previous.loaded || remote.duration!=previous.duration)
+ player.bluetoothChanged();
+ break;
+ case "list":
+ ArrayList<Item> items=new ArrayList<>(); JSONArray array=value.getJSONArray("items");
+ if (array.length()>PAGE_SIZE) throw new IOException("Invalid folder page");
+ for (int i=0;i<array.length();i++) { JSONObject item=array.getJSONObject(i); items.add(new Item(item.getInt("id"),item.getString("name"),item.getBoolean("folder"))); }
+ listing=new Listing(value.getLong("version"),value.getString("title"),value.getBoolean("up"),value.getInt("offset"),value.getInt("total"),items); break;
+ case "error": error=value.getString("message"); break;
+ default: throw new IOException("Unknown Bluetooth response");
+ }
+ }
+ void handle(JSONObject value) throws Exception {
+ switch (value.getString("type")) {
+ case "root": root(); break;
+ case "up":
+ if (tree==null) { root(); break; }
+ if (tracks) loadFolder(directory);
+ else if (!parents.isEmpty()) { String parent=parents.get(parents.size()-1); loadFolder(parent); parents.remove(parents.size()-1); }
+ show(0); break;
+ case "list": show(value.getInt("offset")); break;
+ case "filter": query=value.getString("query"); show(0); break;
+ case "open":
+ if (value.getLong("version")!=version) throw new IOException("Folder changed; refresh it before selecting");
+ int id=value.getInt("id");
+ if (id<0 || id>=entries.size()) throw new IOException("Invalid folder item");
+ Entry entry=entries.get(id);
+ if (entry.folder) { String parent=directory; loadFolder(entry.id); parents.add(parent); show(0); }
+ else if (entry.track!=null) play(entry);
+ else {
+ ArrayList<Track> found=MusicLibrary.tracks(player,entry,entries);
+ if (found.isEmpty()) { if (MusicLibrary.isCue(entry)) throw new IOException("No usable CUE tracks in this folder"); play(entry); }
+ else { entries.clear(); for (Track track:found) entries.add(new Entry(track)); tracks=true; label=entry.name; query=""; version++; show(0); }
+ } break;
+ default:
+ synchronized (BluetoothLink.this) {
+ if (!alive || !current(run) || peer!=this) return;
+ control(value);
+ }
+ }
+ }
+ void control(JSONObject value) throws Exception {
+ switch (value.getString("type")) {
+ case "toggle": player.toggle(); break;
+ case "resume": player.resumePlayback(); break;
+ case "pause": player.pausePlayback(); break;
+ case "stop": player.stopPlayback(); break;
+ case "next": player.next(1); break;
+ case "previous": player.next(-1); break;
+ case "seek": player.seek(milliseconds(value,"position",false)); break;
+ case "a": player.markA(); break;
+ case "b": player.markB(); break;
+ case "loop": player.toggleLoop(); break;
+ case "clear": player.clearLoop(); break;
+ case "markers": player.markers(milliseconds(value,"a",true),milliseconds(value,"b",true)); break;
+ default: throw new IOException("Unknown Bluetooth command");
+ }
+ }
+ void root() throws Exception {
+ String saved=player.getSharedPreferences("MainActivity",Context.MODE_PRIVATE).getString("tree",null);
+ if (saved==null) throw new IOException("Choose a Music folder on the player first");
+ Uri selected=Uri.parse(saved);
+ String root=DocumentsContract.getTreeDocumentId(selected);
+ ArrayList<Entry> found=MusicLibrary.list(player,selected,root);
+ tree=selected; directory=root; entries=found; parents.clear(); tracks=false; query=""; label=root; version++; show(0);
+ }
+ void loadFolder(String target) throws IOException {
+ ArrayList<Entry> found=MusicLibrary.list(player,tree,target);
+ entries=found; directory=target; tracks=false; query=""; label=target; version++;
+ }
+ void show(int offset) throws Exception {
+ if (tree==null) { root(); return; }
+ ArrayList<Integer> matches=new ArrayList<>(); String needle=query.toLowerCase(Locale.ROOT);
+ for (int i=0;i<entries.size();i++) if (entries.get(i).name.toLowerCase(Locale.ROOT).contains(needle)) matches.add(i);
+ int from=Math.max(0,Math.min(offset,Math.max(0,matches.size()-1)));
+ from=from/PAGE_SIZE*PAGE_SIZE;
+ JSONArray items=new JSONArray();
+ for (int i=from;i<Math.min(from+PAGE_SIZE,matches.size());i++) {
+ int id=matches.get(i); Entry e=entries.get(id);
+ items.put(message("item","id",id,"name",e.name,"folder",e.folder));
+ }
+ emit(message("list","version",version,"title",label+(query.isEmpty() ? "" : " · "+query),"up",tracks || !parents.isEmpty(),
+ "offset",from,"total",matches.size(),"items",items));
+ }
+ void play(Entry entry) {
+ ArrayList<Track> queue=new ArrayList<>(); int selected=0;
+ for (Entry candidate:entries) if (!candidate.folder && (candidate==entry || candidate.audio)) {
+ if (candidate==entry) selected=queue.size();
+ queue.add(candidate.track!=null ? candidate.track : new Track(candidate.uri,candidate.name));
+ }
+ synchronized (BluetoothLink.this) { if (alive && current(run) && peer==this) player.play(queue,selected); }
+ }
+ }
+ private static long milliseconds(JSONObject value,String key,boolean unset) throws JSONException,IOException {
+ long ms=value.getLong(key);
+ if (ms<(unset ? -1 : 0) || ms>Long.MAX_VALUE/384000) throw new IOException("Invalid playback time");
+ return ms;
+ }
+ private static JSONObject state(PlaybackService.Snapshot s) {
+ return message("state","title",s.title,"format",s.format,"status",s.status,"position",s.position,"duration",s.duration,
+ "a",s.a,"b",s.b,"playing",s.playing,"loop",s.loop,"loaded",s.loaded);
+ }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
index 0d7c75d..49876d4 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -1,8 +1,10 @@
package net.astropenguin.loopdeck;
import android.app.*;
+import android.bluetooth.*;
+import android.content.pm.PackageManager;
import android.content.*;
-import android.database.Cursor;
+import net.astropenguin.loopdeck.MusicLibrary.Entry;
import android.graphics.Color;
import android.net.Uri;
import android.os.*;
@@ -11,15 +13,12 @@ import android.text.InputType;
import android.view.*;
import android.widget.*;
import java.util.*;
-import java.io.*;
-import java.nio.*;
-import java.nio.charset.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public final class MainActivity extends Activity {
- private static final int PICK_FOLDER=10;
+ private static final int PICK_FOLDER=10, BT_PERMISSION=11;
private static final int TEXT=Color.rgb(232,240,235), MUTED=Color.rgb(159,182,168);
private final Handler main=new Handler(Looper.getMainLooper());
private final ExecutorService files=Executors.newSingleThreadExecutor();
@@ -32,6 +31,11 @@ public final class MainActivity extends Activity {
private Uri tree;
private String directory;
private TextView folderLabel, trackTitle, trackFormat, times, loopTimes, status;
+ private Button choose, bluetoothButton, pageBack, pageNext, find;
+ private LinearLayout remotePages;
+ private BluetoothLink.Listing remoteListing;
+ private boolean wasRemote;
+ private int pendingMode;
private Button play, setA, setB, loopButton, clear, previous, next, stop;
private SeekBar seek;
private EntryAdapter adapter;
@@ -49,7 +53,7 @@ public final class MainActivity extends Activity {
super.onCreate(saved);
buildUi();
bound=bindService(new Intent(this,PlaybackService.class),connection,BIND_AUTO_CREATE);
- String stored=getPreferences(MODE_PRIVATE).getString("tree",null);
+ String stored=getSharedPreferences("MainActivity",MODE_PRIVATE).getString("tree",null);
if (saved!=null) stored=saved.getString("tree",stored);
if (stored!=null) {
tree=Uri.parse(stored);
@@ -75,15 +79,28 @@ public final class MainActivity extends Activity {
root.setPadding(dp(12),dp(6),dp(12),dp(6)); setContentView(root);
LinearLayout top=row();
TextView brand=text("LoopDeck",22,TEXT); top.addView(brand,new LinearLayout.LayoutParams(0,dp(44),1));
- Button choose=new Button(this); choose.setText("Music folder"); choose.setAllCaps(false); choose.setOnClickListener(v -> pickFolder());
+ choose=new Button(this); choose.setText("Music folder"); choose.setAllCaps(false); choose.setOnClickListener(v -> pickFolder());
top.addView(choose); root.addView(top);
+ LinearLayout bluetoothRow=row();
+ bluetoothButton=button(bluetoothRow,"Bluetooth: off",v -> bluetoothMenu());
+ root.addView(bluetoothRow);
+ remotePages=row();
+ pageBack=button(remotePages,"‹ Page",v -> { if (remote() && remoteListing!=null) player.bluetooth.page(remoteListing.offset-BluetoothLink.PAGE_SIZE); });
+ find=button(remotePages,"Find / refresh",v -> remoteSearch());
+ pageNext=button(remotePages,"Page ›",v -> { if (remote() && remoteListing!=null) player.bluetooth.page(remoteListing.offset+BluetoothLink.PAGE_SIZE); });
+ remotePages.setVisibility(View.GONE); root.addView(remotePages);
LinearLayout navigation=row();
Button up=new Button(this); up.setText("↑"); up.setContentDescription("Parent folder"); up.setOnClickListener(v -> goUp());
navigation.addView(up,new LinearLayout.LayoutParams(dp(48),dp(44)));
folderLabel=text("Select a folder on internal storage or microSD",12,MUTED); folderLabel.setMaxLines(2);
navigation.addView(folderLabel,new LinearLayout.LayoutParams(0,dp(44),1)); root.addView(navigation);
ListView list=new ListView(this); adapter=new EntryAdapter(); list.setAdapter(adapter);
- list.setOnItemClickListener((parent,view,position,id) -> select(entries.get(position)));
+ list.setOnItemClickListener((parent,view,position,id) -> {
+ if (remote()) {
+ BluetoothLink.Listing current=remoteListing;
+ if (current!=null && position<current.items.size()) player.bluetooth.open(current.items.get(position),current);
+ } else select(entries.get(position));
+ });
root.addView(list,new LinearLayout.LayoutParams(-1,0,1));
View divider=new View(this); divider.setBackgroundColor(Color.rgb(54,75,63)); root.addView(divider,new LinearLayout.LayoutParams(-1,dp(1)));
trackTitle=text("Choose a track",18,TEXT); trackTitle.setMaxLines(2); trackTitle.setPadding(0,dp(6),0,0); root.addView(trackTitle);
@@ -93,10 +110,10 @@ public final class MainActivity extends Activity {
seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override public void onStartTrackingTouch(SeekBar bar) { dragging=true; }
@Override public void onProgressChanged(SeekBar bar,int value,boolean user) {
- if (user && player!=null) times.setText(time(player.snapshot.duration*value/10000)+" / "+time(player.snapshot.duration));
+ if (user && player!=null) times.setText(time(player.viewSnapshot().duration*value/10000)+" / "+time(player.viewSnapshot().duration));
}
@Override public void onStopTrackingTouch(SeekBar bar) {
- if (player!=null) player.seek(player.snapshot.duration*bar.getProgress()/10000); dragging=false;
+ if (player!=null) player.seek(player.viewSnapshot().duration*bar.getProgress()/10000); dragging=false;
}
});
LinearLayout transport=row();
@@ -115,6 +132,7 @@ public final class MainActivity extends Activity {
status=text("Ready",12,MUTED); status.setMaxLines(3); status.setTextIsSelectable(true); root.addView(status);
}
private void pickFolder() {
+ if (remote()) { player.bluetooth.command("root"); return; }
Intent intent=new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
startActivityForResult(intent,PICK_FOLDER);
@@ -126,11 +144,11 @@ public final class MainActivity extends Activity {
try {
getContentResolver().takePersistableUriPermission(chosen,data.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION);
tree=chosen; directory=DocumentsContract.getTreeDocumentId(tree); parents.clear();
- getPreferences(MODE_PRIVATE).edit().putString("tree",tree.toString()).apply(); listFolder();
+ getSharedPreferences("MainActivity",MODE_PRIVATE).edit().putString("tree",tree.toString()).apply(); listFolder();
} catch (Exception e) { showError("Folder permission failed: "+e.getMessage()); }
}
private void listFolder() {
- if (tree==null) return;
+ if (tree==null || remote()) return;
final Uri selectedTree=tree;
final String selectedDirectory=directory;
trackView=false;
@@ -140,22 +158,11 @@ public final class MainActivity extends Activity {
ArrayList<Entry> result=new ArrayList<>();
String error=null;
try {
- Uri children=DocumentsContract.buildChildDocumentsUriUsingTree(selectedTree,selectedDirectory);
- String[] columns={DocumentsContract.Document.COLUMN_DOCUMENT_ID,DocumentsContract.Document.COLUMN_DISPLAY_NAME,
- DocumentsContract.Document.COLUMN_MIME_TYPE};
- try (Cursor c=getContentResolver().query(children,columns,null,null,null)) {
- if (c==null) throw new IllegalStateException("Storage provider returned no folder listing");
- while (c.moveToNext()) {
- if (request!=generation.get()) return;
- String id=c.getString(0), name=c.getString(1), mime=c.getString(2);
- result.add(new Entry(id,name==null ? id : name,mime,DocumentsContract.buildDocumentUriUsingTree(selectedTree,id)));
- }
- }
- result.sort((x,y) -> x.folder!=y.folder ? (x.folder ? -1 : 1) : x.name.compareToIgnoreCase(y.name));
+ result.addAll(MusicLibrary.list(this,selectedTree,selectedDirectory));
} catch (Exception e) { error="Cannot read folder: "+e.getMessage()+". Choose the folder again if the card changed."; }
final String failure=error;
main.post(() -> {
- if (isDestroyed() || request!=generation.get()) return;
+ if (isDestroyed() || request!=generation.get() || remote()) return;
entries.addAll(result); adapter.notifyDataSetChanged();
folderLabel.setText(selectedDirectory+" · "+result.size()+" items");
if (failure!=null) showError(failure);
@@ -178,39 +185,6 @@ public final class MainActivity extends Activity {
}
ensureForeground(); player.play(tracks,selected);
}
- private static boolean isCue(Entry e) { return e.name.toLowerCase(Locale.ROOT).endsWith(".cue"); }
- private String readCue(Entry e) throws IOException {
- try (InputStream in=getContentResolver().openInputStream(e.uri)) {
- if (in==null) throw new IOException("Cannot open CUE sheet");
- ByteArrayOutputStream out=new ByteArrayOutputStream(); byte[] buffer=new byte[8192]; int n;
- while ((n=in.read(buffer))!=-1) {
- if (out.size()+n>2*1024*1024) throw new IOException("CUE sheet is too large");
- out.write(buffer,0,n);
- }
- byte[] bytes=out.toByteArray();
- if (bytes.length>=2 && ((bytes[0]==(byte)0xff && bytes[1]==(byte)0xfe) ||
- (bytes[0]==(byte)0xfe && bytes[1]==(byte)0xff))) return new String(bytes,StandardCharsets.UTF_16);
- try { return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); }
- catch (CharacterCodingException invalidUtf8) { return new String(bytes,Charset.forName("windows-31j")); }
- }
- }
- private ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image) {
- return cueTracks(text,folder,image,false);
- }
- private ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image,boolean embedded) {
- ArrayList<Track> result=new ArrayList<>();
- for (CueSheet.Part part:CueSheet.parse(text)) {
- Entry target=null;
- String name=part.file.replace('\\','/');
- while (name.startsWith("./")) name=name.substring(2);
- if (image!=null && (embedded || name.isEmpty() || name.equalsIgnoreCase(image.name))) target=image;
- if (image==null) for (Entry candidate:folder)
- if (candidate.audio && candidate.name.equalsIgnoreCase(name)) { target=candidate; break; }
- if (target!=null) result.add(new Track(target.uri,part.number+". "+part.title,part.start,part.end));
- else if (image==null) return new ArrayList<>(); // Never play a partial/misresolved sheet.
- }
- return result;
- }
private void openTrackView(Entry entry) {
final ArrayList<Entry> folder=new ArrayList<>(entries);
final int request=generation.incrementAndGet();
@@ -218,48 +192,18 @@ public final class MainActivity extends Activity {
files.submit(() -> {
ArrayList<Track> found=new ArrayList<>(); String error=null;
try {
- if (isCue(entry)) {
- found=cueTracks(readCue(entry),folder,null);
- if (found.isEmpty()) throw new IOException("No usable audio tracks. CUE FILE names must match audio files in this folder.");
- } else {
- // Prefer a same-named sheet, then sheets that explicitly reference this image.
- String stem=entry.name.replaceFirst("\\.[^.]+$","");
- folder.sort((x,y) -> Boolean.compare(!x.name.equalsIgnoreCase(stem+".cue"),!y.name.equalsIgnoreCase(stem+".cue")));
- for (Entry candidate:folder) if (isCue(candidate)) {
- try { found=cueTracks(readCue(candidate),folder,entry); } catch (IOException ignored) { continue; }
- if (!found.isEmpty()) break;
- }
- if (found.isEmpty()) {
- long handle=0;
- try (ParcelFileDescriptor file=getContentResolver().openFileDescriptor(entry.uri,"r")) {
- if (file==null) throw new IOException("Cannot open selected file");
- handle=NativeDecoder.open(file.getFd());
- found=cueTracks(NativeDecoder.cueSheet(handle),folder,entry,true);
- if (found.isEmpty()) {
- String[] chapters=NativeDecoder.chapters(handle);
- long total=NativeDecoder.duration(handle), previousEnd=-1;
- for (int i=0;i<chapters.length;i+=3) {
- long start=Long.parseLong(chapters[i]), end=Long.parseLong(chapters[i+1]);
- if (end<=start && i+3<chapters.length) end=Long.parseLong(chapters[i+3]);
- if (end<=start) end=total;
- if (start<0 || start<previousEnd || end<=start || (total>0 && end>total)) { found.clear(); break; }
- found.add(new Track(entry.uri,(i/3+1)+". "+chapters[i+2],start,end)); previousEnd=end;
- }
- }
- } finally { if (handle!=0) NativeDecoder.close(handle); }
- }
- }
+ found=MusicLibrary.tracks(this,entry,folder);
} catch (Exception | LinkageError e) { error=e.getMessage(); }
final ArrayList<Track> tracks=found; final String failure=error;
main.post(() -> {
- if (isDestroyed() || request!=generation.get()) return;
+ if (isDestroyed() || request!=generation.get() || remote()) return;
if (!tracks.isEmpty()) {
trackView=true; entries.clear();
for (Track track:tracks) entries.add(new Entry(track));
adapter.notifyDataSetChanged(); folderLabel.setText(entry.name+" · "+tracks.size()+" tracks");
} else {
folderLabel.setText(directory+" · "+entries.size()+" items");
- if (isCue(entry)) showError(failure==null ? "No track information found" : failure);
+ if (MusicLibrary.isCue(entry)) showError(failure==null ? "No track information found" : failure);
else playEntry(entry);
}
});
@@ -267,6 +211,10 @@ public final class MainActivity extends Activity {
}
private void ensureForeground() { startForegroundService(new Intent(this,PlaybackService.class)); }
private boolean goUp() {
+ if (remote()) {
+ if (remoteListing==null || !remoteListing.up) return false;
+ player.bluetooth.command("up"); return true;
+ }
if (trackView) { listFolder(); return true; }
if (parents.isEmpty()) return false;
directory=parents.remove(parents.size()-1); listFolder(); return true;
@@ -274,17 +222,39 @@ public final class MainActivity extends Activity {
@Override public void onBackPressed() { if (!goUp()) super.onBackPressed(); }
private void render() {
if (player==null) return;
- PlaybackService.Snapshot s=player.snapshot;
- replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,s.status);
+ boolean client=remote();
+ if (client!=wasRemote) {
+ wasRemote=client; generation.incrementAndGet(); remoteListing=null;
+ entries.clear(); adapter.notifyDataSetChanged();
+ if (!client) { listFolder(); if (tree==null) folderLabel.setText("Select a folder on internal storage or microSD"); }
+ }
+ remotePages.setVisibility(client ? View.VISIBLE : View.GONE);
+ replace(choose,client ? "Remote root" : "Music folder");
+ BluetoothLink link=player.bluetooth;
+ replace(bluetoothButton,link.status+(link.error.isEmpty() ? "" : " · "+link.error));
+ if (client && remoteListing!=link.listing) {
+ remoteListing=link.listing; adapter.notifyDataSetChanged();
+ replace(folderLabel,remoteListing.title+" · "+(remoteListing.total==0 ? "0 items" : (remoteListing.offset+1)+"–"+(remoteListing.offset+remoteListing.items.size())+" / "+remoteListing.total));
+ }
+ if (client) {
+ boolean ready=link.connected;
+ pageBack.setEnabled(ready && remoteListing!=null && remoteListing.offset>0);
+ pageNext.setEnabled(ready && remoteListing!=null && remoteListing.offset+remoteListing.items.size()<remoteListing.total);
+ find.setEnabled(ready); choose.setEnabled(ready);
+ } else choose.setEnabled(true);
+ PlaybackService.Snapshot s=player.viewSnapshot();
+ replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,link.error.isEmpty() ? s.status : "Bluetooth: "+link.error+"\n"+s.status);
replace(play,s.playing ? "Pause" : "Play"); replace(loopButton,s.loop ? "Loop on" : "Loop off");
if (!dragging) {
replace(times,time(s.position)+" / "+time(s.duration));
int value=s.duration>0 ? (int)Math.min(10000,s.position*10000/s.duration) : 0;
if (seek.getProgress()!=value) seek.setProgress(value);
}
- seek.setEnabled(s.loaded && s.duration>0);
- play.setEnabled(s.loaded); stop.setEnabled(s.loaded);
- setA.setEnabled(s.loaded); setB.setEnabled(s.loaded); loopButton.setEnabled(s.loaded); clear.setEnabled(s.loaded);
+ boolean available=!client || link.connected;
+ previous.setEnabled(available); next.setEnabled(available);
+ seek.setEnabled(available && s.loaded && s.duration>0);
+ play.setEnabled(available && s.loaded); stop.setEnabled(available && s.loaded);
+ setA.setEnabled(available && s.loaded); setB.setEnabled(available && s.loaded); loopButton.setEnabled(available && s.loaded); clear.setEnabled(available && s.loaded);
replace(loopTimes,"A "+(s.a<0 ? "—" : time(s.a))+" B "+(s.b<0 ? "—" : time(s.b))+" · Hold to edit");
}
private static void replace(TextView view,String value) { if (!view.getText().toString().equals(value)) view.setText(value); }
@@ -293,8 +263,8 @@ public final class MainActivity extends Activity {
return String.format(Locale.ROOT,"%d:%02d.%03d",ms/60000,ms/1000%60,ms%1000);
}
private void editMarkers() {
- if (player==null || !player.snapshot.loaded) return;
- PlaybackService.Snapshot s=player.snapshot;
+ if (player==null || !player.viewSnapshot().loaded) return;
+ PlaybackService.Snapshot s=player.viewSnapshot();
LinearLayout form=new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); form.setPadding(dp(20),0,dp(20),0);
form.addView(text("Times in milliseconds. Leave a field blank to unset it.",14,MUTED));
EditText inputA=new EditText(this), inputB=new EditText(this);
@@ -314,6 +284,60 @@ public final class MainActivity extends Activity {
} catch (NumberFormatException e) { inputA.setError("Enter valid milliseconds"); }
})); dialog.show();
}
+ private boolean remote() { return player!=null && player.bluetooth!=null && player.bluetooth.mode==BluetoothLink.CLIENT; }
+ private void bluetoothMenu() {
+ if (player==null) return;
+ new AlertDialog.Builder(this).setTitle("Bluetooth mode")
+ .setItems(new String[]{"Local player / Bluetooth off","Server · play music here","Client · control a paired player"},(dialog,which) -> {
+ if (which==0) { player.stopBluetooth(); render(); }
+ else requestBluetooth(which);
+ }).setNegativeButton("Cancel",null).show();
+ }
+ private void requestBluetooth(int mode) {
+ pendingMode=mode;
+ if (Build.VERSION.SDK_INT>=31 && checkSelfPermission("android.permission.BLUETOOTH_CONNECT")!=PackageManager.PERMISSION_GRANTED) {
+ requestPermissions(new String[]{"android.permission.BLUETOOTH_CONNECT"},BT_PERMISSION); return;
+ }
+ configureBluetooth(mode);
+ }
+ @Override public void onRequestPermissionsResult(int request,String[] permissions,int[] grants) {
+ super.onRequestPermissionsResult(request,permissions,grants);
+ if (request==BT_PERMISSION) {
+ if (grants.length>0 && grants[0]==PackageManager.PERMISSION_GRANTED) configureBluetooth(pendingMode);
+ else showError("Nearby devices permission is required for Bluetooth control.");
+ }
+ }
+ private void configureBluetooth(int mode) {
+ if (player==null) return;
+ try {
+ BluetoothManager manager=(BluetoothManager)getSystemService(BLUETOOTH_SERVICE);
+ BluetoothAdapter bluetooth=manager==null ? null : manager.getAdapter();
+ if (bluetooth==null || !bluetooth.isEnabled()) { showError("Enable Bluetooth in Android Settings first."); return; }
+ if (mode==BluetoothLink.SERVER) {
+ if (tree==null) { showError("Choose a Music folder on this player first."); return; }
+ ensureForeground(); player.startBluetooth(mode,null); render(); return;
+ }
+ ArrayList<BluetoothDevice> devices=new ArrayList<>(bluetooth.getBondedDevices());
+ devices.sort((a,b) -> deviceLabel(a).compareToIgnoreCase(deviceLabel(b)));
+ if (devices.isEmpty()) { showError("Pair the phone and player in Android Bluetooth Settings first, then return here."); return; }
+ String[] labels=new String[devices.size()];
+ for (int i=0;i<labels.length;i++) labels[i]=deviceLabel(devices.get(i));
+ new AlertDialog.Builder(this).setTitle("Paired player · start Server there first").setItems(labels,(dialog,index) -> {
+ ensureForeground(); player.startBluetooth(BluetoothLink.CLIENT,devices.get(index).getAddress()); render();
+ }).setNegativeButton("Cancel",null).show();
+ } catch (Exception e) { showError("Bluetooth: "+e.getMessage()); }
+ }
+ private static String deviceLabel(BluetoothDevice device) {
+ String name=device.getName(); return (name==null ? "Bluetooth device" : name)+" · "+device.getAddress();
+ }
+ private void remoteSearch() {
+ if (!remote()) return;
+ EditText query=new EditText(this); query.setSingleLine(true); query.setHint("Filename in this folder (blank = all)");
+ new AlertDialog.Builder(this).setTitle("Find in remote folder").setView(query)
+ .setPositiveButton("Find",(dialog,which) -> { if (remote()) player.bluetooth.command("filter","query",query.getText().toString()); })
+ .setNeutralButton("Refresh root",(dialog,which) -> { if (remote()) player.bluetooth.command("root"); })
+ .setNegativeButton("Cancel",null).show();
+ }
private void showError(String message) { new AlertDialog.Builder(this).setTitle("LoopDeck").setMessage(message).setPositiveButton("OK",null).show(); }
@Override protected void onResume() { super.onResume(); resumed=true; main.post(tick); }
@Override protected void onPause() { resumed=false; main.removeCallbacks(tick); super.onPause(); }
@@ -325,28 +349,18 @@ public final class MainActivity extends Activity {
generation.incrementAndGet(); files.shutdownNow(); main.removeCallbacks(tick);
if (bound) unbindService(connection); super.onDestroy();
}
- private static final class Entry {
- final String id,name; final Uri uri; final boolean folder,audio;
- final Track track;
- Entry(Track track) {
- this.track=track; id=track.uri.toString(); name=track.name; uri=track.uri; folder=false; audio=true;
- }
- Entry(String id,String name,String mime,Uri uri) {
- this.id=id; this.name=name; this.uri=uri; track=null;
- folder=DocumentsContract.Document.MIME_TYPE_DIR.equals(mime);
- audio=(mime!=null && mime.startsWith("audio/")) || name.toLowerCase(Locale.ROOT).matches(
- ".*\\.(mp3|mp2|mp1|flac|ape|tak|tta|wv|wavpack|wma|wav|wave|aif|aiff|aifc|m4a|mp4|aac|ogg|oga|opus|mpc|mpp|shn|dsf|dff|ac3|dts|amr|au|snd|caf|ra|rm|spx|mka)$");
- }
- }
private final class EntryAdapter extends BaseAdapter {
- @Override public int getCount() { return entries.size(); }
- @Override public Object getItem(int position) { return entries.get(position); }
+ @Override public int getCount() { return remote() ? (remoteListing==null ? 0 : remoteListing.items.size()) : entries.size(); }
+ @Override public Object getItem(int position) { return remote() ? remoteListing.items.get(position) : entries.get(position); }
@Override public long getItemId(int position) { return position; }
@Override public View getView(int position,View reusable,ViewGroup parent) {
TextView view=reusable instanceof TextView ? (TextView)reusable : text("",15,TEXT);
view.setPadding(dp(10),dp(12),dp(6),dp(12)); view.setMinHeight(dp(50)); view.setMaxLines(2);
- Entry e=entries.get(position); view.setText((e.folder ? "▸ " : "")+e.name);
- view.setTextColor(e.folder ? Color.rgb(115,218,193) : TEXT); return view;
+ boolean folder; String name;
+ if (remote()) { BluetoothLink.Item e=remoteListing.items.get(position); folder=e.folder; name=e.name; }
+ else { Entry e=entries.get(position); folder=e.folder; name=e.name; }
+ view.setText((folder ? "▸ " : "")+name);
+ view.setTextColor(folder ? Color.rgb(115,218,193) : TEXT); return view;
}
}
}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java b/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java
new file mode 100644
index 0000000..75bbc7b
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/MusicLibrary.java
@@ -0,0 +1,113 @@
+package net.astropenguin.loopdeck;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import java.io.*;
+import java.nio.*;
+import java.nio.charset.*;
+import java.util.*;
+
+/** Shared local/remote folder and album reader. All methods run off the UI thread. */
+final class MusicLibrary {
+ static final class Entry {
+ final String id,name; final Uri uri; final boolean folder,audio;
+ final Track track;
+ Entry(Track track) {
+ this.track=track; id=track.uri.toString(); name=track.name; uri=track.uri; folder=false; audio=true;
+ }
+ Entry(String id,String name,String mime,Uri uri) {
+ this.id=id; this.name=name; this.uri=uri; track=null;
+ folder=DocumentsContract.Document.MIME_TYPE_DIR.equals(mime);
+ audio=(mime!=null && mime.startsWith("audio/")) || name.toLowerCase(Locale.ROOT).matches(
+ ".*\\.(mp3|mp2|mp1|flac|ape|tak|tta|wv|wavpack|wma|wav|wave|aif|aiff|aifc|m4a|mp4|aac|ogg|oga|opus|mpc|mpp|shn|dsf|dff|ac3|dts|amr|au|snd|caf|ra|rm|spx|mka)$");
+ }
+ }
+
+ static ArrayList<Entry> list(Context context,Uri tree,String directory) throws IOException {
+ ArrayList<Entry> result=new ArrayList<>();
+ Uri children=DocumentsContract.buildChildDocumentsUriUsingTree(tree,directory);
+ String[] columns={DocumentsContract.Document.COLUMN_DOCUMENT_ID,DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_MIME_TYPE};
+ try (Cursor c=context.getContentResolver().query(children,columns,null,null,null)) {
+ if (c==null) throw new IOException("Storage provider returned no folder listing");
+ while (c.moveToNext()) {
+ String id=c.getString(0),name=c.getString(1);
+ result.add(new Entry(id,name==null ? id : name,c.getString(2),DocumentsContract.buildDocumentUriUsingTree(tree,id)));
+ }
+ }
+ result.sort((x,y) -> x.folder!=y.folder ? (x.folder ? -1 : 1) : x.name.compareToIgnoreCase(y.name));
+ return result;
+ }
+ static ArrayList<Track> tracks(Context context,Entry entry,List<Entry> input) throws IOException {
+ ArrayList<Entry> folder=new ArrayList<>(input);
+ ArrayList<Track> found=new ArrayList<>();
+ if (isCue(entry)) {
+ found=cueTracks(readCue(context,entry),folder,null);
+ if (found.isEmpty()) throw new IOException("No usable audio tracks. CUE FILE names must match audio files in this folder.");
+ } else {
+ // Prefer a same-named sheet, then sheets that explicitly reference this image.
+ String stem=entry.name.replaceFirst("\\.[^.]+$","");
+ folder.sort((x,y) -> Boolean.compare(!x.name.equalsIgnoreCase(stem+".cue"),!y.name.equalsIgnoreCase(stem+".cue")));
+ for (Entry candidate:folder) if (isCue(candidate)) {
+ try { found=cueTracks(readCue(context,candidate),folder,entry); } catch (IOException ignored) { continue; }
+ if (!found.isEmpty()) break;
+ }
+ if (found.isEmpty()) {
+ long handle=0;
+ try (ParcelFileDescriptor file=context.getContentResolver().openFileDescriptor(entry.uri,"r")) {
+ if (file==null) throw new IOException("Cannot open selected file");
+ handle=NativeDecoder.open(file.getFd());
+ found=cueTracks(NativeDecoder.cueSheet(handle),folder,entry,true);
+ if (found.isEmpty()) {
+ String[] chapters=NativeDecoder.chapters(handle);
+ long total=NativeDecoder.duration(handle), previousEnd=-1;
+ for (int i=0;i<chapters.length;i+=3) {
+ long start=Long.parseLong(chapters[i]), end=Long.parseLong(chapters[i+1]);
+ if (end<=start && i+3<chapters.length) end=Long.parseLong(chapters[i+3]);
+ if (end<=start) end=total;
+ if (start<0 || start<previousEnd || end<=start || (total>0 && end>total)) { found.clear(); break; }
+ found.add(new Track(entry.uri,(i/3+1)+". "+chapters[i+2],start,end)); previousEnd=end;
+ }
+ }
+ } finally { if (handle!=0) NativeDecoder.close(handle); }
+ }
+ }
+ return found;
+ }
+ static boolean isCue(Entry e) { return e.name.toLowerCase(Locale.ROOT).endsWith(".cue"); }
+ private static String readCue(Context context,Entry e) throws IOException {
+ try (InputStream in=context.getContentResolver().openInputStream(e.uri)) {
+ if (in==null) throw new IOException("Cannot open CUE sheet");
+ ByteArrayOutputStream out=new ByteArrayOutputStream(); byte[] buffer=new byte[8192]; int n;
+ while ((n=in.read(buffer))!=-1) {
+ if (out.size()+n>2*1024*1024) throw new IOException("CUE sheet is too large");
+ out.write(buffer,0,n);
+ }
+ byte[] bytes=out.toByteArray();
+ if (bytes.length>=2 && ((bytes[0]==(byte)0xff && bytes[1]==(byte)0xfe) ||
+ (bytes[0]==(byte)0xfe && bytes[1]==(byte)0xff))) return new String(bytes,StandardCharsets.UTF_16);
+ try { return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); }
+ catch (CharacterCodingException invalidUtf8) { return new String(bytes,Charset.forName("windows-31j")); }
+ }
+ }
+ private static ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image) {
+ return cueTracks(text,folder,image,false);
+ }
+ private static ArrayList<Track> cueTracks(String text,List<Entry> folder,Entry image,boolean embedded) {
+ ArrayList<Track> result=new ArrayList<>();
+ for (CueSheet.Part part:CueSheet.parse(text)) {
+ Entry target=null;
+ String name=part.file.replace('\\','/');
+ while (name.startsWith("./")) name=name.substring(2);
+ if (image!=null && (embedded || name.isEmpty() || name.equalsIgnoreCase(image.name))) target=image;
+ if (image==null) for (Entry candidate:folder)
+ if (candidate.audio && candidate.name.equalsIgnoreCase(name)) { target=candidate; break; }
+ if (target!=null) result.add(new Track(target.uri,part.number+". "+part.title,part.start,part.end));
+ else if (image==null) return new ArrayList<>(); // Never play a partial/misresolved sheet.
+ }
+ return result;
+ }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index 8c2a801..e4b3a7d 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -24,6 +24,7 @@ public final class PlaybackService extends Service {
private final LinkedBlockingQueue<Command> commands = new LinkedBlockingQueue<>();
private volatile boolean running = true;
private Thread worker;
+ BluetoothLink bluetooth;
private AudioManager audioManager;
private AudioFocusRequest focus;
private PowerManager.WakeLock wake;
@@ -73,6 +74,7 @@ public final class PlaybackService extends Service {
@Override public void onCreate() {
super.onCreate();
+ bluetooth=new BluetoothLink(this);
audioManager = (AudioManager)getSystemService(AUDIO_SERVICE);
wake = ((PowerManager)getSystemService(POWER_SERVICE)).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoopDeck:playback");
wake.setReferenceCounted(false);
@@ -84,8 +86,8 @@ public final class PlaybackService extends Service {
new NotificationChannel(CHANNEL, "Music playback", NotificationManager.IMPORTANCE_LOW));
session = new MediaSession(this, "LoopDeck");
session.setCallback(new MediaSession.Callback() {
- @Override public void onPlay() { submit(() -> setPlaying(true)); }
- @Override public void onPause() { submit(() -> { resumeOnFocus=false; setPlaying(false); }); }
+ @Override public void onPlay() { resumePlayback(); }
+ @Override public void onPause() { pausePlayback(); }
@Override public void onStop() { stopPlayback(); }
@Override public void onSkipToNext() { next(1); }
@Override public void onSkipToPrevious() { next(-1); }
@@ -109,41 +111,72 @@ public final class PlaybackService extends Service {
if ("next".equals(action)) next(1);
if ("previous".equals(action)) next(-1);
if ("stop".equals(action)) stopPlayback();
+ if ("bluetoothOff".equals(action)) stopBluetooth();
}
return START_NOT_STICKY;
}
+ Snapshot viewSnapshot() { return bluetooth!=null && bluetooth.mode==BluetoothLink.CLIENT ? bluetooth.remote : snapshot; }
+ private boolean remoteCommand(String command,Object... args) {
+ if (bluetooth==null || bluetooth.mode!=BluetoothLink.CLIENT) return false;
+ bluetooth.command(command,args); return true;
+ }
+ void resumePlayback() { if (!remoteCommand("resume")) submit(() -> setPlaying(true)); }
+ void pausePlayback() { if (!remoteCommand("pause")) submit(() -> { resumeOnFocus=false; setPlaying(false); }); }
+ void startBluetooth(int mode,String address) {
+ // Stop local sound before entering remote mode; leave precise decoding on the server.
+ if (mode==BluetoothLink.CLIENT) submit(() -> { resumeOnFocus=false; setPlaying(false); });
+ bluetooth.start(mode,address);
+ bluetoothChanged();
+ }
+ void bluetoothChanged() {
+ new Handler(getMainLooper()).post(() -> {
+ if (!running) return;
+ Snapshot s=viewSnapshot();
+ session.setMetadata(new MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE,s.title)
+ .putLong(MediaMetadata.METADATA_KEY_DURATION,s.duration).build());
+ updateSession();
+ });
+ }
+ void stopBluetooth() {
+ bluetooth.close(); session.setActive(true); bluetoothChanged();
+ if (!snapshot.loaded) { stopForeground(true); foreground=false; stopSelf(); }
+ else ((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).notify(NOTIFICATION,notification());
+ }
private void submit(Command command) { if (running) commands.offer(command); }
void play(List<Track> tracks, int selected) {
List<Track> copy = new ArrayList<>(tracks);
submit(() -> { queue=copy; load(selected); });
}
- void toggle() { submit(() -> { resumeOnFocus=false; setPlaying(!playing); }); }
- void next(int direction) { submit(() -> { if (!queue.isEmpty()) load(Math.floorMod(index + direction, queue.size())); }); }
- void seek(long ms) { submit(() -> {
+ void toggle() { if (remoteCommand("toggle")) return; submit(() -> { resumeOnFocus=false; setPlaying(!playing); }); }
+ void next(int direction) { if (remoteCommand(direction<0 ? "previous" : "next")) return; submit(() -> { if (!queue.isEmpty()) load(Math.floorMod(index + direction, queue.size())); }); }
+ void seek(long ms) { if (remoteCommand("seek","position",ms)) return; submit(() -> {
if (decoder == 0) return;
long target = LoopMath.frame(Math.max(0, duration > 0 ? Math.min(ms,duration) : ms), rate);
if (loop && (target < a || target >= b)) { loop=false; cache=null; }
resetAt(target); status=loop ? "A–B loop" : "Ready";
}); }
- void markA() { submit(() -> { if (decoder!=0) changeMarkers(positionFrames(), b); }); }
- void markB() { submit(() -> { if (decoder!=0) changeMarkers(a, positionFrames()); }); }
- void markers(long aMs, long bMs) { submit(() -> {
+ void markA() { if (remoteCommand("a")) return; submit(() -> { if (decoder!=0) changeMarkers(positionFrames(), b); }); }
+ void markB() { if (remoteCommand("b")) return; submit(() -> { if (decoder!=0) changeMarkers(a, positionFrames()); }); }
+ void markers(long aMs, long bMs) { if (remoteCommand("markers","a",aMs,"b",bMs)) return; submit(() -> {
if (decoder!=0) changeMarkers(aMs < 0 ? -1 : LoopMath.frame(aMs,rate), bMs < 0 ? -1 : LoopMath.frame(bMs,rate));
}); }
- void clearLoop() { submit(() -> {
+ void clearLoop() { if (remoteCommand("clear")) return; submit(() -> {
if (decoder==0) return;
long here=positionFrames(); loop=false; a=b=-1; cache=null; resetAt(here); status="Loop cleared";
}); }
- void toggleLoop() { submit(() -> {
+ void toggleLoop() { if (remoteCommand("loop")) return; submit(() -> {
if (decoder==0) return;
if (!LoopMath.valid(a,b)) { status="Set A, then set B after A"; return; }
if (loop) { long here=positionFrames(); loop=false; cache=null; resetAt(here); status="Loop off"; }
else activateLoop();
}); }
- void stopPlayback() { submit(() -> {
+ void stopPlayback() { if (remoteCommand("stop")) return; submit(() -> {
setPlaying(false); releaseAudio(); title="Stopped"; format=""; status="Choose a track";
a=b=-1; loop=false; duration=0; updateSession();
- new Handler(getMainLooper()).post(() -> { stopForeground(true); foreground=false; stopSelf(); });
+ new Handler(getMainLooper()).post(() -> {
+ if (bluetooth!=null && bluetooth.mode!=BluetoothLink.LOCAL) return;
+ stopForeground(true); foreground=false; stopSelf();
+ });
}); }
private void changeMarkers(long newA, long newB) throws IOException {
long total=duration > 0 ? LoopMath.frame(duration,rate) : Long.MAX_VALUE;
@@ -414,28 +447,31 @@ public final class PlaybackService extends Service {
}
private void updateSession() { updateSession(true); }
private void updateSession(boolean notify) {
+ Snapshot s=viewSnapshot();
session.setPlaybackState(new PlaybackState.Builder().setActions(PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE
| PlaybackState.ACTION_PLAY_PAUSE | PlaybackState.ACTION_STOP | PlaybackState.ACTION_SEEK_TO
| PlaybackState.ACTION_SKIP_TO_NEXT | PlaybackState.ACTION_SKIP_TO_PREVIOUS)
- .setState(playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,snapshot.position,playing ? 1f : 0f).build());
+ .setState(s.playing ? PlaybackState.STATE_PLAYING : PlaybackState.STATE_PAUSED,s.position,s.playing ? 1f : 0f).build());
if (notify && foreground) ((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).notify(NOTIFICATION,notification());
}
private PendingIntent action(String name) {
return PendingIntent.getService(this,name.hashCode(),new Intent(this,PlaybackService.class).setAction(name),PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
}
private Notification notification() {
- Snapshot s=snapshot;
+ Snapshot s=viewSnapshot();
+ boolean linked=bluetooth!=null && bluetooth.mode!=BluetoothLink.LOCAL;
PendingIntent open=PendingIntent.getActivity(this,0,new Intent(this,MainActivity.class),PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
return new Notification.Builder(this,CHANNEL).setSmallIcon(R.drawable.ic_music).setContentTitle(s.title)
- .setContentText(s.playing ? "Playing" : "Paused").setContentIntent(open).setOnlyAlertOnce(true)
- .setOngoing(s.playing).setVisibility(Notification.VISIBILITY_PUBLIC)
+ .setContentText(linked ? bluetooth.status : s.playing ? "Playing" : "Paused").setContentIntent(open).setOnlyAlertOnce(true)
+ .setOngoing(s.playing || linked).setVisibility(Notification.VISIBILITY_PUBLIC)
.addAction(new Notification.Action.Builder(android.R.drawable.ic_media_previous,"Previous",action("previous")).build())
.addAction(new Notification.Action.Builder(s.playing ? android.R.drawable.ic_media_pause : android.R.drawable.ic_media_play,s.playing ? "Pause" : "Play",action("toggle")).build())
.addAction(new Notification.Action.Builder(android.R.drawable.ic_media_next,"Next",action("next")).build())
- .addAction(new Notification.Action.Builder(android.R.drawable.ic_menu_close_clear_cancel,"Stop",action("stop")).build())
+ .addAction(new Notification.Action.Builder(android.R.drawable.ic_menu_close_clear_cancel,linked ? "Bluetooth off" : "Stop",action(linked ? "bluetoothOff" : "stop")).build())
.setStyle(new Notification.MediaStyle().setMediaSession(session.getSessionToken()).setShowActionsInCompactView(0,1,2)).build();
}
@Override public void onDestroy() {
+ if (bluetooth!=null) bluetooth.close();
unregisterReceiver(noisy); running=false; worker.interrupt();
prefetcher.shutdownNow();
// Native IO may still be unwinding; the worker releases its own resources.
diff --git a/app/src/main/java/net/astropenguin/loopdeck/RemoteWire.java b/app/src/main/java/net/astropenguin/loopdeck/RemoteWire.java
new file mode 100644
index 0000000..6285bf5
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/RemoteWire.java
@@ -0,0 +1,22 @@
+package net.astropenguin.loopdeck;
+
+import java.io.*;
+import java.nio.ByteBuffer;
+import java.nio.charset.*;
+
+/** Length-prefixed UTF-8 JSON; bounded before allocating or parsing. */
+final class RemoteWire {
+ static final int VERSION=1, MAX_BYTES=64*1024;
+ static String read(DataInputStream in) throws IOException {
+ int size=in.readInt();
+ if (size<1 || size>MAX_BYTES) throw new IOException("Invalid Bluetooth message size");
+ byte[] bytes=new byte[size]; in.readFully(bytes);
+ try { return StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString(); }
+ catch (CharacterCodingException e) { throw new IOException("Invalid Bluetooth UTF-8",e); }
+ }
+ static void write(DataOutputStream out,String json) throws IOException {
+ byte[] bytes=json.getBytes(StandardCharsets.UTF_8);
+ if (bytes.length<1 || bytes.length>MAX_BYTES) throw new IOException("Bluetooth message is too large");
+ out.writeInt(bytes.length); out.write(bytes); out.flush();
+ }
+}
diff --git a/scripts/test-bluetooth.py b/scripts/test-bluetooth.py
new file mode 100644
index 0000000..ba29f89
--- /dev/null
+++ b/scripts/test-bluetooth.py
@@ -0,0 +1,162 @@
+#!/usr/bin/env python3
+"""Exercise the actual BluetoothLink/MusicLibrary over fake RFCOMM (loopback TCP).
+JDK 17+ and a host org.json jar are required: python3 scripts/test-bluetooth.py /path/json.jar
+The jar is test-only; the APK uses Android's built-in org.json.
+"""
+from pathlib import Path
+import subprocess, tempfile, sys
+repo=Path(__file__).resolve().parents[1]
+json=Path(sys.argv[1]).resolve()
+with tempfile.TemporaryDirectory(prefix='loopdeck-bluetooth-') as temp:
+ root=Path(temp)
+ def put(name,body):
+ p=root/(name+'.java'); p.parent.mkdir(parents=True,exist_ok=True); p.write_text(body)
+ def stub(name,body,base=''):
+ package,cls=name.rsplit('/',1)
+ put(name,'package '+package.replace('/','.')+'; public class '+cls+base+' {'+body+'}')
+ stub('android/os/Looper','public static Looper getMainLooper(){return new Looper();}')
+ stub('android/os/Handler','''
+ static final java.util.concurrent.ScheduledExecutorService timer=java.util.concurrent.Executors.newSingleThreadScheduledExecutor(r->{Thread t=new Thread(r);t.setDaemon(true);return t;});
+ final java.util.Map<Runnable,java.util.concurrent.Future<?>> jobs=new java.util.concurrent.ConcurrentHashMap<>();
+ public Handler(Looper l){} public void postDelayed(Runnable r,long ms){jobs.put(r,timer.schedule(r,ms,java.util.concurrent.TimeUnit.MILLISECONDS));}
+ public void removeCallbacks(Runnable r){java.util.concurrent.Future<?> f=jobs.remove(r);if(f!=null)f.cancel(false);}
+ ''')
+ stub('android/os/SystemClock','public static long elapsedRealtime(){return System.nanoTime()/1000000;}')
+ stub('android/net/Uri','public final String id; public Uri(String id){this.id=id;} public static Uri parse(String id){return new Uri(id);} public String toString(){return id;}')
+ stub('android/os/ParcelFileDescriptor','public int getFd(){return 1;} public void close(){}',' implements AutoCloseable')
+ stub('android/content/Context','public static final String BLUETOOTH_SERVICE="bluetooth"; public static final int MODE_PRIVATE=0; public Object getSystemService(String n){return null;} public SharedPreferences getSharedPreferences(String n,int m){return new SharedPreferences();} public ContentResolver getContentResolver(){return new ContentResolver();}')
+ stub('android/content/SharedPreferences','public String getString(String k,String fallback){return "root";}')
+ stub('android/content/ContentResolver','''
+ public android.database.Cursor query(android.net.Uri uri,String[] columns,String a,String[] b,String c){
+ java.util.List<String[]> rows=new java.util.ArrayList<>();
+ if(uri.id.equals("root")){
+ rows.add(new String[]{"album","Album","dir"});
+ for(int i=0;i<127;i++)rows.add(new String[]{"track"+i,String.format(java.util.Locale.ROOT,"Track %03d 日本語🎵.flac",i),"audio/flac"});
+ }else if(uri.id.equals("album")){
+ rows.add(new String[]{"image","album.flac","audio/flac"}); rows.add(new String[]{"cue","album.cue","text/plain"});
+ }
+ return new android.database.Cursor(rows);
+ }
+ public android.os.ParcelFileDescriptor openFileDescriptor(android.net.Uri u,String m){return new android.os.ParcelFileDescriptor();}
+ public java.io.InputStream openInputStream(android.net.Uri u){return new java.io.ByteArrayInputStream(("FILE \\\"album.flac\\\" WAVE\\n TRACK 01 AUDIO\\n TITLE \\\"One\\\"\\n INDEX 01 00:00:00\\n TRACK 02 AUDIO\\n TITLE \\\"Two\\\"\\n INDEX 01 01:00:00\\n").getBytes(java.nio.charset.StandardCharsets.UTF_8));}
+ ''')
+ stub('android/database/Cursor','final java.util.List<String[]> rows;int pos=-1;public Cursor(java.util.List<String[]> rows){this.rows=rows;} public boolean moveToNext(){return ++pos<rows.size();} public String getString(int col){return rows.get(pos)[col];} public void close(){}',' implements AutoCloseable')
+ stub('android/provider/DocumentsContract','''
+ public static String getTreeDocumentId(android.net.Uri u){return u.id;}
+ public static android.net.Uri buildChildDocumentsUriUsingTree(android.net.Uri t,String id){return new android.net.Uri(id);}
+ public static android.net.Uri buildDocumentUriUsingTree(android.net.Uri t,String id){return new android.net.Uri(id);}
+ public static class Document {public static final String COLUMN_DOCUMENT_ID="id",COLUMN_DISPLAY_NAME="name",COLUMN_MIME_TYPE="mime",MIME_TYPE_DIR="dir";}
+ ''')
+ stub('android/bluetooth/BluetoothManager','final BluetoothAdapter adapter; public BluetoothManager(BluetoothAdapter a){adapter=a;} public BluetoothAdapter getAdapter(){return adapter;}')
+ stub('android/bluetooth/BluetoothAdapter','''
+ public static final java.util.Map<String,BluetoothAdapter> adapters=new java.util.concurrent.ConcurrentHashMap<>();
+ public volatile BluetoothServerSocket server; public final String name;
+ public BluetoothAdapter(String name){this.name=name;adapters.put(name,this);}
+ public boolean isEnabled(){return true;} public BluetoothDevice getRemoteDevice(String address){return new BluetoothDevice(address);}
+ public BluetoothServerSocket listenUsingRfcommWithServiceRecord(String n,java.util.UUID id)throws java.io.IOException{server=new BluetoothServerSocket();return server;}
+ ''')
+ stub('android/bluetooth/BluetoothDevice','''
+ public static final int BOND_BONDED=12; final String name;public BluetoothDevice(String n){name=n;}
+ public int getBondState(){return BOND_BONDED;} public String getName(){return name;}
+ public BluetoothSocket createRfcommSocketToServiceRecord(java.util.UUID id){return new BluetoothSocket(this);}
+ ''')
+ stub('android/bluetooth/BluetoothServerSocket','''
+ final java.net.ServerSocket socket; public BluetoothServerSocket()throws java.io.IOException{socket=new java.net.ServerSocket(0);}
+ public BluetoothSocket accept()throws java.io.IOException{return new BluetoothSocket(socket.accept(),new BluetoothDevice("phone"));}
+ public void close()throws java.io.IOException{socket.close();}
+ ''')
+ stub('android/bluetooth/BluetoothSocket','''
+ final BluetoothDevice device; java.net.Socket socket;
+ public BluetoothSocket(BluetoothDevice device){this.device=device;}
+ public BluetoothSocket(java.net.Socket socket,BluetoothDevice device){this.device=device;this.socket=socket;}
+ public void connect()throws java.io.IOException{socket=new java.net.Socket("127.0.0.1",BluetoothAdapter.adapters.get(device.name).server.socket.getLocalPort());}
+ public BluetoothDevice getRemoteDevice(){return device;}
+ public java.io.InputStream getInputStream()throws java.io.IOException{return socket.getInputStream();}
+ public java.io.OutputStream getOutputStream()throws java.io.IOException{return socket.getOutputStream();}
+ public void close()throws java.io.IOException{if(socket!=null)socket.close();}
+ ''')
+ stub('net/astropenguin/loopdeck/NativeDecoder','''
+ static int openCount; static long open(int fd){openCount++;return 1;} static void close(long h){openCount--;}
+ static String cueSheet(long h){return "";} static String[] chapters(long h){return new String[0];} static long duration(long h){return 120000;}
+ ''')
+ stub('net/astropenguin/loopdeck/PlaybackService','''
+ final android.bluetooth.BluetoothAdapter adapter;
+ PlaybackService(String name){adapter=new android.bluetooth.BluetoothAdapter(name);}
+ public Object getSystemService(String n){return new android.bluetooth.BluetoothManager(adapter);}
+ static final class Snapshot {
+ final String title,format,status; final long position,duration,a,b; final boolean playing,loop,loaded;
+ Snapshot(String t,String f,String s,long p,long d,long a,long b,boolean play,boolean loop,boolean loaded){title=t;format=f;status=s;position=p;duration=d;this.a=a;this.b=b;playing=play;this.loop=loop;this.loaded=loaded;}
+ }
+ volatile Snapshot snapshot=new Snapshot("日本語🎵","FLAC 96k","Ready",1234,120000,-1,-1,true,false,true);
+ final java.util.List<String> calls=new java.util.concurrent.CopyOnWriteArrayList<>();
+ volatile java.util.List<Track> queue; volatile int selected;
+ void play(java.util.List<Track> q,int i){queue=q;selected=i;calls.add("play");}
+ void bluetoothChanged(){}
+ void toggle(){calls.add("toggle");} void resumePlayback(){calls.add("resume");} void pausePlayback(){calls.add("pause");}
+ void stopPlayback(){calls.add("stop");} void next(int d){calls.add(d>0?"next":"previous");}
+ void seek(long ms){calls.add("seek:"+ms);} void markA(){calls.add("a");} void markB(){calls.add("b");}
+ void toggleLoop(){calls.add("loop");} void clearLoop(){calls.add("clear");} void markers(long a,long b){calls.add("markers:"+a+":"+b);}
+ ''',' extends android.content.Context')
+ put('net/astropenguin/loopdeck/BluetoothTest',r'''package net.astropenguin.loopdeck;
+import java.io.*;
+import java.util.*;
+import java.util.function.BooleanSupplier;
+import android.bluetooth.*;
+public final class BluetoothTest {
+ static void expect(boolean ok){if(!ok)throw new AssertionError();}
+ static void await(BooleanSupplier test)throws Exception{long end=System.nanoTime()+5_000_000_000L;while(!test.getAsBoolean()){if(System.nanoTime()>end)throw new AssertionError("Timed out");Thread.sleep(5);}}
+ static void fails(byte[] bytes)throws Exception{try{RemoteWire.read(new DataInputStream(new ByteArrayInputStream(bytes)));throw new AssertionError("accepted bad frame");}catch(IOException expected){}}
+ public static void main(String[] args)throws Exception{
+ ByteArrayOutputStream bytes=new ByteArrayOutputStream();DataOutputStream out=new DataOutputStream(bytes);
+ RemoteWire.write(out,"日本語🎵");RemoteWire.write(out,"second");DataInputStream in=new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()));
+ expect(RemoteWire.read(in).equals("日本語🎵") && RemoteWire.read(in).equals("second"));
+ fails(new byte[]{0,1,0,1}); fails(new byte[]{-1,-1,-1,-1}); fails(new byte[]{0,0,0,0});
+ fails(new byte[]{0,0,0,3,65}); fails(new byte[]{0,0,0,1,(byte)0xff});
+ try{RemoteWire.write(out,"x".repeat(RemoteWire.MAX_BYTES+1));throw new AssertionError();}catch(IOException expected){}
+ PlaybackService serverPlayer=new PlaybackService("fiio"),clientPlayer=new PlaybackService("phone");
+ BluetoothLink server=new BluetoothLink(serverPlayer),client=new BluetoothLink(clientPlayer);
+ try {
+ server.start(BluetoothLink.SERVER,null);await(()->serverPlayer.adapter.server!=null);
+ client.start(BluetoothLink.CLIENT,"fiio");await(()->client.connected && server.connected && client.listing.total==128);
+ expect(client.listing.items.size()==BluetoothLink.PAGE_SIZE);
+ await(()->client.remote.title.equals("日本語🎵"));expect(client.remote.position==1234 && client.remote.playing);
+ BluetoothLink.Listing root=client.listing;
+ client.page(50);await(()->client.listing.offset==50);expect(client.listing.items.get(0).id==50);
+ client.open(client.listing.items.get(0),client.listing);await(()->serverPlayer.calls.contains("play"));
+ expect(serverPlayer.queue.size()==127 && serverPlayer.selected==49); // Includes tracks outside this page.
+ expect(serverPlayer.queue.get(49).uri.toString().equals("track49"));
+ client.command("filter","query","126");await(()->client.listing.total==1);expect(client.listing.items.get(0).name.contains("126"));
+ client.command("root");await(()->client.listing.total==128 && client.listing.version!=root.version);
+ client.open(root.items.get(1),root);await(()->client.error.contains("Folder changed")); // Reject stale indices.
+ client.open(client.listing.items.get(0),client.listing);await(()->client.listing.total==2);
+ BluetoothLink.Listing album=client.listing;
+ client.open(album.items.get(0),album);await(()->client.listing.items.get(0).name.equals("1. One"));
+ expect(client.listing.total==2 && client.listing.up);
+ client.open(client.listing.items.get(1),client.listing);await(()->serverPlayer.queue.size()==2);
+ expect(serverPlayer.selected==1 && serverPlayer.queue.get(1).startMs==60000);
+ client.command("up");await(()->client.listing.items.get(0).name.equals("album.cue"));
+ client.command("up");await(()->client.listing.total==128);
+ for(String command:List.of("toggle","resume","pause","next","previous","a","b","loop","clear","stop"))client.command(command);
+ client.command("seek","position",3456);client.command("markers","a",1000,"b",2500);
+ await(()->serverPlayer.calls.contains("markers:1000:2500"));
+ expect(serverPlayer.calls.containsAll(List.of("toggle","resume","pause","next","previous","a","b","loop","clear","stop","seek:3456")));
+ client.command("seek","position",-5);await(()->client.error.equals("Invalid playback time"));
+ expect(!serverPlayer.calls.contains("seek:-5"));
+ client.close();await(()->!server.connected);expect(!client.remote.loaded && client.listing.items.isEmpty());
+ client.start(BluetoothLink.CLIENT,"fiio");await(()->client.connected && client.listing.total==128);
+ // A malformed peer is disconnected; the server can accept the next client.
+ client.close();await(()->!server.connected);
+ BluetoothSocket invalid=clientPlayer.adapter.getRemoteDevice("fiio").createRfcommSocketToServiceRecord(java.util.UUID.randomUUID());invalid.connect();
+ RemoteWire.write(new DataOutputStream(invalid.getOutputStream()),BluetoothLink.message("hello","version",999,"role",BluetoothLink.CLIENT).toString());
+ await(()->server.error.contains("Incompatible"));invalid.close();
+ client.start(BluetoothLink.CLIENT,"fiio");await(()->client.connected && client.listing.total==128);
+ expect(NativeDecoder.openCount==0);
+ }finally{client.close();server.close();}
+ expect(client.mode==BluetoothLink.LOCAL && server.mode==BluetoothLink.LOCAL);
+ System.out.println("Bluetooth: framing bounds/UTF-8, handshake, folder paging/filter, full-folder queue, CUE tracks, stale selection rejection, all commands, invalid times, disconnect/reconnect and malformed peer recovery passed");
+ }
+}''')
+ app=repo/'app/src/main/java/net/astropenguin/loopdeck'
+ sources=list(root.rglob('*.java'))+[app/(name+'.java') for name in ['BluetoothLink','RemoteWire','MusicLibrary','Track','CueSheet']]
+ subprocess.run(['java','-m','jdk.compiler/com.sun.tools.javac.Main','--release','17','-cp',str(json),'-d',str(root/'classes'),*map(str,sources)],check=True)
+ subprocess.run(['java','-cp',str(root/'classes')+':'+str(json),'net.astropenguin.loopdeck.BluetoothTest'],check=True,timeout=40)
diff --git a/scripts/test-playback-handoff.py b/scripts/test-playback-handoff.py
index 0b75099..6377105 100644
--- a/scripts/test-playback-handoff.py
+++ b/scripts/test-playback-handoff.py
@@ -15,7 +15,7 @@ classes={
'android/content/Intent':'String action; public Intent(Context c,Class<?> t){} public Intent setAction(String a){action=a;return this;} public String getAction(){return action;}',
'android/content/IntentFilter':'public IntentFilter(String a){}',
'android/content/BroadcastReceiver':'public abstract void onReceive(Context c,Intent i);',
-'android/app/Service':'public static final int START_NOT_STICKY=2; public void onCreate(){} public android.os.IBinder onBind(android.content.Intent i){return null;} public int onStartCommand(android.content.Intent i,int f,int id){return 0;} public void onDestroy(){} public android.os.Looper getMainLooper(){return new android.os.Looper();} public void startForeground(int i,Notification n){} public void stopForeground(boolean b){} public void stopSelf(){}',
+'android/app/Service':'public static final int START_NOT_STICKY=2; public void onCreate(){} public android.os.IBinder onBind(android.content.Intent i){return null;} public int onStartCommand(android.content.Intent i,int f,int id){return 0;} public void onDestroy(){} public android.os.Looper getMainLooper(){return new android.os.Looper();} public void startForeground(int i,Notification n){} public void stopForeground(boolean b){} public int stops; public void stopSelf(){stops++;}',
'android/app/NotificationManager':'public static final int IMPORTANCE_LOW=1; public void createNotificationChannel(NotificationChannel c){} public void notify(int i,Notification n){}',
'android/app/NotificationChannel':'public NotificationChannel(String a,String b,int c){}',
'android/app/PendingIntent':'public static final int FLAG_UPDATE_CURRENT=1,FLAG_IMMUTABLE=2; public static PendingIntent getService(android.content.Context c,int i,android.content.Intent n,int f){return new PendingIntent();} public static PendingIntent getActivity(android.content.Context c,int i,android.content.Intent n,int f){return new PendingIntent();}',
@@ -38,6 +38,7 @@ classes={
'android/R':'public static class drawable{public static final int ic_media_previous=1,ic_media_pause=2,ic_media_play=3,ic_media_next=4,ic_menu_close_clear_cancel=5;}',
'net/astropenguin/loopdeck/R':'public static class drawable{public static final int ic_music=1;}',
'net/astropenguin/loopdeck/MainActivity':'',
+'net/astropenguin/loopdeck/BluetoothLink':'static final int LOCAL=0,SERVER=1,CLIENT=2; int mode; String status=""; PlaybackService.Snapshot remote=new PlaybackService.Snapshot("remote","","disconnected",0,0,-1,-1,false,false,false); BluetoothLink(PlaybackService p){} final java.util.List<String> calls=new java.util.ArrayList<>(); void command(String s,Object... args){calls.add(s);} void start(int m,String a){mode=m;} void close(){mode=0;}',
}
for name,body in classes.items():
pkg,cls=name.rsplit('/',1); modifier='abstract ' if cls=='BroadcastReceiver' else ''; kind='interface' if cls=='IBinder' else 'class';extends=' extends android.content.Context' if cls=='Service' else ' implements IBinder' if cls=='Binder' else ' implements AutoCloseable' if cls=='ParcelFileDescriptor' else ''
@@ -78,7 +79,7 @@ static void seek(long h,long frame){states.get(h)[1]=(int)frame;}
static int read(long h,float[] out,int frames){int[] s=states.get(h);int n=Math.min(frames,size(s[0])-s[1]);for(int i=0;i<n;i++){out[i*2]=s[0]*100000+s[1];out[i*2+1]=-out[i*2];s[1]++;}return n;}
}''')
-put("net/astropenguin/loopdeck/PlaybackHandoffTest",'package net.astropenguin.loopdeck;\nimport java.lang.reflect.*;\nimport java.util.*;\nimport android.media.*;\nimport android.media.session.*;\nimport android.os.*;\nimport android.net.*;\npublic class PlaybackHandoffTest {\n static Field field(Object o,String name)throws Exception{Field f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f;}\n static Object get(Object o,String name)throws Exception{return field(o,name).get(o);}\n static void set(Object o,String name,Object value)throws Exception{field(o,name).set(o,value);}\n static Object call(Object o,String name,Class<?>[] types,Object...args)throws Exception{Method m=o.getClass().getDeclaredMethod(name,types);m.setAccessible(true);try{return m.invoke(o,args);}catch(InvocationTargetException e){throw new AssertionError(name,e.getCause());}}\n static Object call(Object o,String name)throws Exception{return call(o,name,new Class<?>[0]);}\n static void expect(boolean b){if(!b)throw new AssertionError();}\n static PlaybackService create(List<Track> queue)throws Exception{\n PlaybackService s=new PlaybackService();set(s,"audioManager",new AudioManager());set(s,"wake",new PowerManager().newWakeLock(1,"test"));set(s,"session",new MediaSession(s,"test"));set(s,"queue",queue);call(s,"load",new Class<?>[]{int.class},0);return s;\n }\n static void ready(PlaybackService s)throws Exception{\n Object slot=get(s,"nextSlot");if(slot==null)return;\n long end=System.nanoTime()+2_000_000_000L;\n while(get(slot,"value")==null&&System.nanoTime()<end)Thread.sleep(1);\n expect(get(slot,"value")!=null);\n }\n static void finish(PlaybackService s)throws Exception{call(s,"releaseAudio");((java.util.concurrent.ExecutorService)get(s,"prefetcher")).shutdown();expect(((java.util.concurrent.ExecutorService)get(s,"prefetcher")).awaitTermination(2,java.util.concurrent.TimeUnit.SECONDS));expect(NativeDecoder.states.isEmpty());}\n public static void main(String[]args)throws Exception{\n List<Track> queue=List.of(new Track(new Uri(1),"one"),new Track(new Uri(2),"two"),new Track(new Uri(3),"three"));\n PlaybackService s=create(queue);ready(s);AudioTrack out=(AudioTrack)get(s,"output");int created=AudioTrack.created,flushed=AudioTrack.flushed;\n int lastIndex=0;\n for(int step=0;step<10000;step++){\n call(s,"adoptTransition");int index=(int)get(s,"index");if(index!=lastIndex){ready(s);lastIndex=index;}\n call(s,"pump");call(s,"publish");\n // Metadata/position must follow the audible track, even while next PCM is queued.\n long musicHead=out.head-44100;\n int audible=musicHead<7003?0:musicHead<16010?1:2;\n if(musicHead<0)expect(s.snapshot.position==0);\n if(musicHead!=7003 && musicHead!=16010)expect(((int)get(s,"index"))==audible);\n out.advance(64);\n if(!(boolean)get(s,"playing"))break;\n }\n expect(AudioTrack.created==created && AudioTrack.flushed==flushed);\n expect(out.samples.size()==(44100+7003+9007+6001)*2);\n int pos=0;for(int i=0;i<44100*2;i++)expect(out.samples.get(pos++)==0f);\n for(int id=1;id<=3;id++)for(int i=0;i<NativeDecoder.size(id);i++){\n expect(out.samples.get(pos++)==id*100000+i);expect(out.samples.get(pos++)==-(id*100000+i));\n }\n expect(s.snapshot.position==NativeDecoder.size(3)*1000L/44100);\n finish(s);\n s=create(queue);ready(s);out=(AudioTrack)get(s,"output");\n // Reach a queued transition while the old track is still audible, then seek.\n for(int step=0;step<10000 && get(s,"transition")==null;step++){call(s,"pump");out.advance(16);}\n expect(get(s,"transition")!=null && out.head<44100+7003);\n call(s,"pump"); // Put some prefetched samples into the output queue.\n call(s,"resetAt",new Class<?>[]{long.class},1000L);\n expect(get(s,"transition")==null && (int)get(s,"index")==0);\n call(s,"pump");call(s,"pump");\n expect(out.samples.get(0)==101000f); // Outgoing decoder is correctly restored.\n finish(s);\n // Seeking during warmup discards all silence and starts exactly at the target.\n s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n call(s,"pump");call(s,"pump");expect(out.samples.get(0)==0f);\n call(s,"resetAt",new Class<?>[]{long.class},1000L);\n call(s,"pump");call(s,"pump");expect(out.samples.get(0)==101000f);\n finish(s);\n // Replay after EOF warms the existing output again.\n s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n for(int step=0;step<10000 && (boolean)get(s,"playing");step++){call(s,"pump");out.advance(64);}\n expect(!(boolean)get(s,"playing"));\n call(s,"setPlaying",new Class<?>[]{boolean.class},true);\n call(s,"pump");call(s,"pump");call(s,"publish");\n expect(out.samples.get(0)==0f && s.snapshot.position==0);\n finish(s);\n // Always request the file rate, with the existing 48 kHz unsupported-rate fallback.\n s=create(List.of(new Track(new Uri(4),"96k")));\n expect((int)get(s,"rate")==96000 && (long)get(s,"warmupRemaining")==96000);\n finish(s);\n AudioTrack.rejectedRate=96000;\n s=create(List.of(new Track(new Uri(4),"96k fallback")));\n expect((int)get(s,"rate")==48000 && (long)get(s,"warmupRemaining")==48000);\n expect(((String)get(s,"format")).contains("48000 Hz"));\n finish(s);AudioTrack.rejectedRate=0;\n System.out.println("Playback service: one-second silent startup, EOF replay, warmup seek cancellation, three-track exact concatenation, single AudioTrack, audible metadata, seek cancellation and resource cleanup passed");\n }\n}\n')
+put("net/astropenguin/loopdeck/PlaybackHandoffTest",'package net.astropenguin.loopdeck;\nimport java.lang.reflect.*;\nimport java.util.*;\nimport android.media.*;\nimport android.media.session.*;\nimport android.os.*;\nimport android.net.*;\npublic class PlaybackHandoffTest {\n static Field field(Object o,String name)throws Exception{Field f=o.getClass().getDeclaredField(name);f.setAccessible(true);return f;}\n static Object get(Object o,String name)throws Exception{return field(o,name).get(o);}\n static void set(Object o,String name,Object value)throws Exception{field(o,name).set(o,value);}\n static Object call(Object o,String name,Class<?>[] types,Object...args)throws Exception{Method m=o.getClass().getDeclaredMethod(name,types);m.setAccessible(true);try{return m.invoke(o,args);}catch(InvocationTargetException e){throw new AssertionError(name,e.getCause());}}\n static Object call(Object o,String name)throws Exception{return call(o,name,new Class<?>[0]);}\n static void expect(boolean b){if(!b)throw new AssertionError();}\n static PlaybackService create(List<Track> queue)throws Exception{\n PlaybackService s=new PlaybackService();set(s,"audioManager",new AudioManager());set(s,"wake",new PowerManager().newWakeLock(1,"test"));set(s,"session",new MediaSession(s,"test"));set(s,"queue",queue);call(s,"load",new Class<?>[]{int.class},0);return s;\n }\n static void ready(PlaybackService s)throws Exception{\n Object slot=get(s,"nextSlot");if(slot==null)return;\n long end=System.nanoTime()+2_000_000_000L;\n while(get(slot,"value")==null&&System.nanoTime()<end)Thread.sleep(1);\n expect(get(slot,"value")!=null);\n }\n static void finish(PlaybackService s)throws Exception{call(s,"releaseAudio");((java.util.concurrent.ExecutorService)get(s,"prefetcher")).shutdown();expect(((java.util.concurrent.ExecutorService)get(s,"prefetcher")).awaitTermination(2,java.util.concurrent.TimeUnit.SECONDS));expect(NativeDecoder.states.isEmpty());}\n public static void main(String[]args)throws Exception{\n List<Track> queue=List.of(new Track(new Uri(1),"one"),new Track(new Uri(2),"two"),new Track(new Uri(3),"three"));\n PlaybackService s=create(queue);ready(s);AudioTrack out=(AudioTrack)get(s,"output");int created=AudioTrack.created,flushed=AudioTrack.flushed;\n int lastIndex=0;\n for(int step=0;step<10000;step++){\n call(s,"adoptTransition");int index=(int)get(s,"index");if(index!=lastIndex){ready(s);lastIndex=index;}\n call(s,"pump");call(s,"publish");\n // Metadata/position must follow the audible track, even while next PCM is queued.\n long musicHead=out.head-44100;\n int audible=musicHead<7003?0:musicHead<16010?1:2;\n if(musicHead<0)expect(s.snapshot.position==0);\n if(musicHead!=7003 && musicHead!=16010)expect(((int)get(s,"index"))==audible);\n out.advance(64);\n if(!(boolean)get(s,"playing"))break;\n }\n expect(AudioTrack.created==created && AudioTrack.flushed==flushed);\n expect(out.samples.size()==(44100+7003+9007+6001)*2);\n int pos=0;for(int i=0;i<44100*2;i++)expect(out.samples.get(pos++)==0f);\n for(int id=1;id<=3;id++)for(int i=0;i<NativeDecoder.size(id);i++){\n expect(out.samples.get(pos++)==id*100000+i);expect(out.samples.get(pos++)==-(id*100000+i));\n }\n expect(s.snapshot.position==NativeDecoder.size(3)*1000L/44100);\n finish(s);\n s=create(queue);ready(s);out=(AudioTrack)get(s,"output");\n // Reach a queued transition while the old track is still audible, then seek.\n for(int step=0;step<10000 && get(s,"transition")==null;step++){call(s,"pump");out.advance(16);}\n expect(get(s,"transition")!=null && out.head<44100+7003);\n call(s,"pump"); // Put some prefetched samples into the output queue.\n call(s,"resetAt",new Class<?>[]{long.class},1000L);\n expect(get(s,"transition")==null && (int)get(s,"index")==0);\n call(s,"pump");call(s,"pump");\n expect(out.samples.get(0)==101000f); // Outgoing decoder is correctly restored.\n finish(s);\n // Seeking during warmup discards all silence and starts exactly at the target.\n s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n call(s,"pump");call(s,"pump");expect(out.samples.get(0)==0f);\n call(s,"resetAt",new Class<?>[]{long.class},1000L);\n call(s,"pump");call(s,"pump");expect(out.samples.get(0)==101000f);\n finish(s);\n // Replay after EOF warms the existing output again.\n s=create(List.of(queue.get(0)));out=(AudioTrack)get(s,"output");\n for(int step=0;step<10000 && (boolean)get(s,"playing");step++){call(s,"pump");out.advance(64);}\n expect(!(boolean)get(s,"playing"));\n call(s,"setPlaying",new Class<?>[]{boolean.class},true);\n call(s,"pump");call(s,"pump");call(s,"publish");\n expect(out.samples.get(0)==0f && s.snapshot.position==0);\n finish(s);\n // Always request the file rate, with the existing 48 kHz unsupported-rate fallback.\n s=create(List.of(new Track(new Uri(4),"96k")));\n expect((int)get(s,"rate")==96000 && (long)get(s,"warmupRemaining")==96000);\n finish(s);\n AudioTrack.rejectedRate=96000;\n s=create(List.of(new Track(new Uri(4),"96k fallback")));\n expect((int)get(s,"rate")==48000 && (long)get(s,"warmupRemaining")==48000);\n expect(((String)get(s,"format")).contains("48000 Hz"));\n finish(s);AudioTrack.rejectedRate=0;\n // All remote controls route to Bluetooth without adding local playback work.\n s=create(List.of(queue.get(0)));s.bluetooth=new BluetoothLink(s);s.startBluetooth(BluetoothLink.CLIENT,"fiio");\n s.bluetooth.remote=new PlaybackService.Snapshot("remote","format","ready",50,1000,0,100,true,true,true);\n expect(s.viewSnapshot()==s.bluetooth.remote);\n int queued=((java.util.Queue<?>)get(s,"commands")).size();\n s.toggle();s.resumePlayback();s.pausePlayback();s.next(1);s.next(-1);s.seek(100);s.markA();s.markB();s.markers(1,2);s.toggleLoop();s.clearLoop();s.stopPlayback();\n expect(((java.util.Queue<?>)get(s,"commands")).size()==queued);\n expect(s.bluetooth.calls.equals(List.of("toggle","resume","pause","next","previous","seek","a","b","markers","loop","clear","stop")));\n ((java.util.Queue<?>)get(s,"commands")).clear();\n s.bluetooth.start(BluetoothLink.SERVER,null);s.stopPlayback();\n Object stopCommand=((java.util.Queue<?>)get(s,"commands")).poll();call(stopCommand,"run");call(s,"publish");\n expect(s.stops==0 && s.bluetooth.mode==BluetoothLink.SERVER); // Stop music keeps the server alive.\n s.stopBluetooth();expect(s.stops==1 && s.bluetooth.mode==BluetoothLink.LOCAL);\n finish(s);\n System.out.println("Playback service: one-second silent startup, EOF replay, warmup seek cancellation, three-track exact concatenation, single AudioTrack, audible metadata, seek cancellation and resource cleanup passed");\n }\n}\n')
app=repo/'app/src/main/java/net/astropenguin/loopdeck'
classes=root/'classes'