penguin/loopdeck

A simple player that support A-B looping for FiiO M11S

commit def581b9a858a6faf0754c86ea7baafc5c214a16

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-18T03:24:49Z
subjectAdded 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'