penguin/loopdeck

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

commit 65a388d4cf028bf32d7c589ae6025738acc824ca

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-18T04:25:25Z
subjectRefactor
commit 65a388d4cf028bf32d7c589ae6025738acc824ca
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-18T04:25:25Z

    Refactor
---
 .gitignore                                         |   1 +
 README.md                                          |  16 +-
 VALIDATION.md                                      |  22 ++
 .../net/astropenguin/loopdeck/MainActivity.java    | 232 ++++++--------------
 .../net/astropenguin/loopdeck/PlaybackService.java |  79 +++----
 .../astropenguin/loopdeck/PlayerController.java    | 236 +++++++++++++++++++++
 app/src/main/res/drawable/ic_music.xml             |  53 ++++-
 scripts/test-bluetooth.py                          |  61 +++++-
 scripts/test-playback-handoff.py                   |   4 +-
 9 files changed, 481 insertions(+), 223 deletions(-)

diff --git a/.gitignore b/.gitignore
index d3ac873..a5105d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ local.properties
 third_party/ffmpeg/
 *.iml
 *.swp
+.DS_Store
diff --git a/README.md b/README.md
index cdbbfbe..b2388cb 100644
--- a/README.md
+++ b/README.md
@@ -153,7 +153,9 @@ app data unless you intend to re-grant folder permission.
 
 ## Source layout
 
-- `MainActivity.java`: folder listing and native controls; file queries use a worker.
+- `MainActivity.java`: native controls, Android permission/picker dialogs and rendering.
+- `PlayerController.java`: local/Bluetooth routing, common browser rows/capabilities,
+  folder workers and playback commands shared by the UI and media controls.
 - `PlaybackService.java`: single playback worker, AudioTrack, loop cache and media session.
 - `decoder.cpp`: FFmpeg custom file-descriptor IO, decoding, resampling and seeking.
 - `tak1.cpp`: native TAK 1.0 decoding, selected automatically for codec type 0.
@@ -265,3 +267,15 @@ 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.
+
+### Controller boundary
+
+UI and media commands go through `PlayerController`: `perform(Action)`, `seek`,
+`markers`, `open`, `page`, `up`, `filter`, `refresh` and `chooseFolder`. The controller
+owns local/remote selection and publishes one immutable `Browser` model for either
+source. `PlaybackService` methods always operate on local audio; the Bluetooth
+server invokes those local methods for incoming commands. The activity never
+sends protocol messages or decides how to route playback/browsing commands.
+
+The paired-device dialog says **Select a paired device** when there are devices
+to choose from. An empty list shows the original Server/pairing setup message.
diff --git a/VALIDATION.md b/VALIDATION.md
index ae7d8a2..66e5faf 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -174,3 +174,25 @@ 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.
+
+## Controller refactor (base def581b)
+
+- Added one service-owned `PlayerController` for local/Bluetooth command routing,
+  snapshot selection, folder workers and a common immutable browser model. Removed
+  remote routing from PlaybackService's local playback API and the activity's
+  click handlers/list adapter. Media-session and notification actions enter the
+  same controller as the activity.
+- Expanded the Bluetooth host integration test to run the actual controller with
+  the actual protocol and library reader. Verified all actions locally and over
+  the link, snapshot selection, shared folder/CUE navigation, paging/filtering,
+  folder-picker routing, preservation of a live CUE view during state restore,
+  returning to local mode and cancellation of an in-flight local metadata result
+  when entering Client. Remote actions do not invoke the local playback engine.
+- Existing protocol and playback-handoff tests passed, including keeping Server
+  alive after stopping playback. Java sources compiled against Android API 35;
+  `git diff --check` and patch applicability checks passed.
+- Nonempty paired-device lists now use “Select a paired device”; empty lists
+  retain the original setup title and show the pairing instructions.
+
+No APK was built. Device UI, permission dialogs and real Bluetooth remain checks
+for the owner after applying the patch.
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
index 49876d4..db44ba5 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -4,37 +4,27 @@ import android.app.*;
 import android.bluetooth.*;
 import android.content.pm.PackageManager;
 import android.content.*;
-import net.astropenguin.loopdeck.MusicLibrary.Entry;
 import android.graphics.Color;
 import android.net.Uri;
 import android.os.*;
-import android.provider.DocumentsContract;
 import android.text.InputType;
 import android.view.*;
 import android.widget.*;
 import java.util.*;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicInteger;
+import static net.astropenguin.loopdeck.PlayerController.Action.*;
 
 public final class MainActivity extends Activity {
     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();
-    private final AtomicInteger generation=new AtomicInteger();
-    private final ArrayList<Entry> entries=new ArrayList<>();
-    private final ArrayList<String> parents=new ArrayList<>();
-    private PlaybackService player;
-    private boolean bound, dragging, resumed;
-    private boolean trackView;
-    private Uri tree;
-    private String directory;
+    private PlayerController controller;
+    private PlayerController.Browser browser=PlayerController.Browser.empty();
+    private Bundle restoredBrowser;
+    private Uri pendingFolder;
+    private boolean bound,dragging,resumed;
     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;
@@ -42,9 +32,12 @@ public final class MainActivity extends Activity {
 
     private final ServiceConnection connection=new ServiceConnection() {
         @Override public void onServiceConnected(ComponentName name, IBinder binder) {
-            player=((PlaybackService.LocalBinder)binder).service(); render();
+            controller=((PlaybackService.LocalBinder)binder).service().controller;
+            controller.restoreBrowser(restoredBrowser); restoredBrowser=null;
+            if (pendingFolder!=null) { controller.musicFolder(pendingFolder); pendingFolder=null; }
+            render();
         }
-        @Override public void onServiceDisconnected(ComponentName name) { player=null; }
+        @Override public void onServiceDisconnected(ComponentName name) { controller=null; }
     };
     private final Runnable tick=new Runnable() {
         @Override public void run() { if (resumed) { render(); main.postDelayed(this,250); } }
@@ -53,14 +46,8 @@ public final class MainActivity extends Activity {
         super.onCreate(saved);
         buildUi();
         bound=bindService(new Intent(this,PlaybackService.class),connection,BIND_AUTO_CREATE);
-        String stored=getSharedPreferences("MainActivity",MODE_PRIVATE).getString("tree",null);
-        if (saved!=null) stored=saved.getString("tree",stored);
-        if (stored!=null) {
-            tree=Uri.parse(stored);
-            directory=saved==null ? DocumentsContract.getTreeDocumentId(tree) : saved.getString("directory",DocumentsContract.getTreeDocumentId(tree));
-            if (saved!=null && saved.getStringArrayList("parents")!=null) parents.addAll(saved.getStringArrayList("parents"));
-            listFolder();
-        }
+        restoredBrowser=saved;
+        if (saved!=null && saved.getString("pendingFolder")!=null) pendingFolder=Uri.parse(saved.getString("pendingFolder"));
     }
     private int dp(int value) { return Math.round(getResources().getDisplayMetrics().density*value); }
     private TextView text(String content, int sp, int color) {
@@ -79,15 +66,15 @@ 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));
-        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 -> { if (controller!=null) controller.chooseFolder(this::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); });
+        pageBack=button(remotePages,"‹ Page",v -> { if (controller!=null) controller.page(-1); });
         find=button(remotePages,"Find / refresh",v -> remoteSearch());
-        pageNext=button(remotePages,"Page ›",v -> { if (remote() && remoteListing!=null) player.bluetooth.page(remoteListing.offset+BluetoothLink.PAGE_SIZE); });
+        pageNext=button(remotePages,"Page ›",v -> { if (controller!=null) controller.page(1); });
         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());
@@ -95,12 +82,7 @@ public final class MainActivity extends Activity {
         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) -> {
-            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));
-        });
+        list.setOnItemClickListener((parent,view,position,id) -> { if (controller!=null) controller.open(browser,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);
@@ -110,29 +92,28 @@ 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.viewSnapshot().duration*value/10000)+" / "+time(player.viewSnapshot().duration));
+                if (user && controller!=null) times.setText(time(controller.snapshot().duration*value/10000)+" / "+time(controller.snapshot().duration));
             }
             @Override public void onStopTrackingTouch(SeekBar bar) {
-                if (player!=null) player.seek(player.viewSnapshot().duration*bar.getProgress()/10000); dragging=false;
+                if (controller!=null) controller.seek(controller.snapshot().duration*bar.getProgress()/10000); dragging=false;
             }
         });
         LinearLayout transport=row();
-        previous=button(transport,"Previous",v -> { if (player!=null) { ensureForeground(); player.next(-1); } });
-        play=button(transport,"Play",v -> { if (player!=null) { ensureForeground(); player.toggle(); } });
-        next=button(transport,"Next",v -> { if (player!=null) { ensureForeground(); player.next(1); } });
-        stop=button(transport,"Stop",v -> { if (player!=null) player.stopPlayback(); }); root.addView(transport);
+        previous=button(transport,"Previous",v -> control(PREVIOUS));
+        play=button(transport,"Play",v -> control(TOGGLE));
+        next=button(transport,"Next",v -> control(NEXT));
+        stop=button(transport,"Stop",v -> control(STOP)); root.addView(transport);
         LinearLayout markers=row();
-        setA=button(markers,"Set A",v -> { if (player!=null) player.markA(); });
-        setB=button(markers,"Set B",v -> { if (player!=null) player.markB(); });
-        loopButton=button(markers,"Loop off",v -> { if (player!=null) player.toggleLoop(); });
-        clear=button(markers,"Clear",v -> { if (player!=null) player.clearLoop(); }); root.addView(markers);
+        setA=button(markers,"Set A",v -> control(MARK_A));
+        setB=button(markers,"Set B",v -> control(MARK_B));
+        loopButton=button(markers,"Loop off",v -> control(LOOP));
+        clear=button(markers,"Clear",v -> control(CLEAR)); root.addView(markers);
         setA.setOnLongClickListener(v -> { editMarkers(); return true; });
         setB.setOnLongClickListener(v -> { editMarkers(); return true; });
         loopTimes=text("A —     B —   ·   Hold Set A/B to edit times",11,MUTED); root.addView(loopTimes);
         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);
@@ -143,114 +124,30 @@ public final class MainActivity extends Activity {
         Uri chosen=data.getData();
         try {
             getContentResolver().takePersistableUriPermission(chosen,data.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION);
-            tree=chosen; directory=DocumentsContract.getTreeDocumentId(tree); parents.clear();
-            getSharedPreferences("MainActivity",MODE_PRIVATE).edit().putString("tree",tree.toString()).apply(); listFolder();
+            if (controller!=null) controller.musicFolder(chosen); else pendingFolder=chosen;
         } catch (Exception e) { showError("Folder permission failed: "+e.getMessage()); }
     }
-    private void listFolder() {
-        if (tree==null || remote()) return;
-        final Uri selectedTree=tree;
-        final String selectedDirectory=directory;
-        trackView=false;
-        final int request=generation.incrementAndGet();
-        entries.clear(); adapter.notifyDataSetChanged(); folderLabel.setText("Loading folder…");
-        files.submit(() -> {
-            ArrayList<Entry> result=new ArrayList<>();
-            String error=null;
-            try {
-                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() || remote()) return;
-                entries.addAll(result); adapter.notifyDataSetChanged();
-                folderLabel.setText(selectedDirectory+" · "+result.size()+" items");
-                if (failure!=null) showError(failure);
-            });
-        });
-    }
-    private void select(Entry entry) {
-        if (entry.folder) { parents.add(directory); directory=entry.id; listFolder(); return; }
-        if (player==null) { showError("Player is still starting"); return; }
-        if (entry.track==null) { openTrackView(entry); return; }
-        playEntry(entry);
-    }
-    private void playEntry(Entry entry) {
-        ArrayList<Track> tracks=new ArrayList<>(); int selected=0;
-        for (Entry candidate:entries) {
-            if (!candidate.folder && (candidate==entry || candidate.audio)) {
-                if (candidate==entry) selected=tracks.size();
-                tracks.add(candidate.track!=null ? candidate.track : new Track(candidate.uri,candidate.name));
-            }
-        }
-        ensureForeground(); player.play(tracks,selected);
-    }
-    private void openTrackView(Entry entry) {
-        final ArrayList<Entry> folder=new ArrayList<>(entries);
-        final int request=generation.incrementAndGet();
-        folderLabel.setText("Reading track information…");
-        files.submit(() -> {
-            ArrayList<Track> found=new ArrayList<>(); String error=null;
-            try {
-                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() || 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 (MusicLibrary.isCue(entry)) showError(failure==null ? "No track information found" : failure);
-                    else playEntry(entry);
-                }
-            });
-        });
-    }
-    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;
-    }
+    private void control(PlayerController.Action action) { if (controller!=null) controller.perform(action); }
+    private boolean goUp() { return controller!=null && controller.up(); }
     @Override public void onBackPressed() { if (!goUp()) super.onBackPressed(); }
     private void render() {
-        if (player==null) return;
-        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);
+        if (controller==null) return;
+        PlayerController.Browser current=controller.browser();
+        if (browser!=current) { browser=current; adapter.notifyDataSetChanged(); }
+        remotePages.setVisibility(browser.paged ? View.VISIBLE : View.GONE);
+        replace(choose,browser.rootLabel); choose.setEnabled(browser.ready);
+        replace(folderLabel,browser.title);
+        replace(bluetoothButton,controller.bluetoothStatus());
+        pageBack.setEnabled(browser.previous); pageNext.setEnabled(browser.next); find.setEnabled(browser.ready);
+        PlaybackService.Snapshot s=controller.snapshot();
+        replace(trackTitle,s.title); replace(trackFormat,s.format); replace(status,controller.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);
         }
-        boolean available=!client || link.connected;
+        boolean available=controller.available();
         previous.setEnabled(available); next.setEnabled(available);
         seek.setEnabled(available && s.loaded && s.duration>0);
         play.setEnabled(available && s.loaded); stop.setEnabled(available && s.loaded);
@@ -263,8 +160,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.viewSnapshot().loaded) return;
-        PlaybackService.Snapshot s=player.viewSnapshot();
+        if (controller==null || !controller.snapshot().loaded) return;
+        PlaybackService.Snapshot s=controller.snapshot();
         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);
@@ -280,16 +177,15 @@ public final class MainActivity extends Activity {
                 long newB=inputB.getText().length()==0 ? -1 : Long.parseLong(inputB.getText().toString());
                 if (newA>Long.MAX_VALUE/384000 || newB>Long.MAX_VALUE/384000) throw new NumberFormatException();
                 if (newA>=0 && newB>=0 && newB<=newA) { inputB.setError("B must be after A"); return; }
-                if (player!=null) player.markers(newA,newB); dialog.dismiss();
+                if (controller!=null) controller.markers(newA,newB); dialog.dismiss();
             } 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;
+        if (controller==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(); }
+                if (which==0) { controller.stopBluetooth(); render(); }
                 else requestBluetooth(which);
             }).setNegativeButton("Cancel",null).show();
     }
@@ -308,22 +204,26 @@ public final class MainActivity extends Activity {
         }
     }
     private void configureBluetooth(int mode) {
-        if (player==null) return;
+        if (controller==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;
+                if (!controller.hasMusicFolder()) { showError("Choose a Music folder on this player first."); return; }
+                controller.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; }
+            if (devices.isEmpty()) {
+                new AlertDialog.Builder(this).setTitle("Paired player · start Server there first")
+                    .setMessage("Pair the phone and player in Android Bluetooth Settings first, then return here.")
+                    .setPositiveButton("OK",null).show(); 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();
+            new AlertDialog.Builder(this).setTitle("Select a paired device").setItems(labels,(dialog,index) -> {
+                controller.startBluetooth(BluetoothLink.CLIENT,devices.get(index).getAddress()); render();
             }).setNegativeButton("Cancel",null).show();
         } catch (Exception e) { showError("Bluetooth: "+e.getMessage()); }
     }
@@ -331,11 +231,11 @@ public final class MainActivity extends Activity {
         String name=device.getName(); return (name==null ? "Bluetooth device" : name)+" · "+device.getAddress();
     }
     private void remoteSearch() {
-        if (!remote()) return;
+        if (controller==null) 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"); })
+            .setPositiveButton("Find",(dialog,which) -> { if (controller!=null) controller.filter(query.getText().toString()); })
+            .setNeutralButton("Refresh root",(dialog,which) -> { if (controller!=null) controller.refresh(); })
             .setNegativeButton("Cancel",null).show();
     }
     private void showError(String message) { new AlertDialog.Builder(this).setTitle("LoopDeck").setMessage(message).setPositiveButton("OK",null).show(); }
@@ -343,22 +243,22 @@ public final class MainActivity extends Activity {
     @Override protected void onPause() { resumed=false; main.removeCallbacks(tick); super.onPause(); }
     @Override protected void onSaveInstanceState(Bundle out) {
         super.onSaveInstanceState(out);
-        if (tree!=null) { out.putString("tree",tree.toString()); out.putString("directory",directory); out.putStringArrayList("parents",parents); }
+        if (controller!=null) controller.saveBrowser(out);
+        else if (restoredBrowser!=null) out.putAll(restoredBrowser);
+        if (pendingFolder!=null) out.putString("pendingFolder",pendingFolder.toString());
     }
     @Override protected void onDestroy() {
-        generation.incrementAndGet(); files.shutdownNow(); main.removeCallbacks(tick);
+        main.removeCallbacks(tick);
         if (bound) unbindService(connection); super.onDestroy();
     }
     private final class EntryAdapter extends BaseAdapter {
-        @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 int getCount() { return browser.items.size(); }
+        @Override public Object getItem(int position) { return browser.items.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);
-            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; }
+            PlayerController.Item item=browser.items.get(position); boolean folder=item.folder; String name=item.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/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index e4b3a7d..4949c61 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -24,7 +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;
+    PlayerController controller;
     private AudioManager audioManager;
     private AudioFocusRequest focus;
     private PowerManager.WakeLock wake;
@@ -74,7 +74,7 @@ public final class PlaybackService extends Service {
 
     @Override public void onCreate() {
         super.onCreate();
-        bluetooth=new BluetoothLink(this);
+        controller=new PlayerController(this);
         audioManager = (AudioManager)getSystemService(AUDIO_SERVICE);
         wake = ((PowerManager)getSystemService(POWER_SERVICE)).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoopDeck:playback");
         wake.setReferenceCounted(false);
@@ -86,12 +86,12 @@ 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() { resumePlayback(); }
-            @Override public void onPause() { pausePlayback(); }
-            @Override public void onStop() { stopPlayback(); }
-            @Override public void onSkipToNext() { next(1); }
-            @Override public void onSkipToPrevious() { next(-1); }
-            @Override public void onSeekTo(long pos) { seek(pos); }
+            @Override public void onPlay() { controller.perform(PlayerController.Action.RESUME); }
+            @Override public void onPause() { controller.perform(PlayerController.Action.PAUSE); }
+            @Override public void onStop() { controller.perform(PlayerController.Action.STOP); }
+            @Override public void onSkipToNext() { controller.perform(PlayerController.Action.NEXT); }
+            @Override public void onSkipToPrevious() { controller.perform(PlayerController.Action.PREVIOUS); }
+            @Override public void onSeekTo(long pos) { controller.seek(pos); }
         });
         session.setActive(true);
         registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
@@ -107,38 +107,29 @@ public final class PlaybackService extends Service {
         startForeground(NOTIFICATION, notification()); foreground = true;
         if (intent != null) {
             String action = intent.getAction();
-            if ("toggle".equals(action)) toggle();
-            if ("next".equals(action)) next(1);
-            if ("previous".equals(action)) next(-1);
-            if ("stop".equals(action)) stopPlayback();
-            if ("bluetoothOff".equals(action)) stopBluetooth();
+            if ("toggle".equals(action)) controller.perform(PlayerController.Action.TOGGLE);
+            if ("next".equals(action)) controller.perform(PlayerController.Action.NEXT);
+            if ("previous".equals(action)) controller.perform(PlayerController.Action.PREVIOUS);
+            if ("stop".equals(action)) controller.perform(PlayerController.Action.STOP);
+            if ("bluetoothOff".equals(action)) controller.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();
-    }
+    private Snapshot currentSnapshot() { return controller==null ? snapshot : controller.snapshot(); }
+    // Local playback API. User/media commands enter through PlayerController.
+    void resumePlayback() { submit(() -> setPlaying(true)); }
+    void pausePlayback() { submit(() -> { resumeOnFocus=false; setPlaying(false); }); }
     void bluetoothChanged() {
         new Handler(getMainLooper()).post(() -> {
             if (!running) return;
-            Snapshot s=viewSnapshot();
+            Snapshot s=currentSnapshot();
             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();
+    void bluetoothStopped() {
+        session.setActive(true); bluetoothChanged();
         if (!snapshot.loaded) { stopForeground(true); foreground=false; stopSelf(); }
         else ((NotificationManager)getSystemService(NOTIFICATION_SERVICE)).notify(NOTIFICATION,notification());
     }
@@ -147,34 +138,34 @@ public final class PlaybackService extends Service {
         List<Track> copy = new ArrayList<>(tracks);
         submit(() -> { queue=copy; load(selected); });
     }
-    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(() -> {
+    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(() -> {
         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() { 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(() -> {
+    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(() -> {
         if (decoder!=0) changeMarkers(aMs < 0 ? -1 : LoopMath.frame(aMs,rate), bMs < 0 ? -1 : LoopMath.frame(bMs,rate));
     }); }
-    void clearLoop() { if (remoteCommand("clear")) return; submit(() -> {
+    void clearLoop() { submit(() -> {
         if (decoder==0) return;
         long here=positionFrames(); loop=false; a=b=-1; cache=null; resetAt(here); status="Loop cleared";
     }); }
-    void toggleLoop() { if (remoteCommand("loop")) return; submit(() -> {
+    void toggleLoop() { 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() { if (remoteCommand("stop")) return; submit(() -> {
+    void stopPlayback() { submit(() -> {
         setPlaying(false); releaseAudio(); title="Stopped"; format=""; status="Choose a track";
         a=b=-1; loop=false; duration=0; updateSession();
         new Handler(getMainLooper()).post(() -> {
-            if (bluetooth!=null && bluetooth.mode!=BluetoothLink.LOCAL) return;
+            if (controller!=null && controller.bluetoothEnabled()) return;
             stopForeground(true); foreground=false; stopSelf();
         });
     }); }
@@ -447,7 +438,7 @@ public final class PlaybackService extends Service {
     }
     private void updateSession() { updateSession(true); }
     private void updateSession(boolean notify) {
-        Snapshot s=viewSnapshot();
+        Snapshot s=currentSnapshot();
         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)
@@ -458,11 +449,11 @@ public final class PlaybackService extends Service {
         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=viewSnapshot();
-        boolean linked=bluetooth!=null && bluetooth.mode!=BluetoothLink.LOCAL;
+        Snapshot s=currentSnapshot();
+        boolean linked=controller!=null && controller.bluetoothEnabled();
         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(linked ? bluetooth.status : s.playing ? "Playing" : "Paused").setContentIntent(open).setOnlyAlertOnce(true)
+            .setContentText(linked ? controller.bluetoothStatus() : 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())
@@ -471,7 +462,7 @@ public final class PlaybackService extends Service {
             .setStyle(new Notification.MediaStyle().setMediaSession(session.getSessionToken()).setShowActionsInCompactView(0,1,2)).build();
     }
     @Override public void onDestroy() {
-        if (bluetooth!=null) bluetooth.close();
+        if (controller!=null) controller.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/PlayerController.java b/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
new file mode 100644
index 0000000..d216e0d
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
@@ -0,0 +1,236 @@
+package net.astropenguin.loopdeck;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.DocumentsContract;
+import java.util.*;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.atomic.AtomicInteger;
+import net.astropenguin.loopdeck.MusicLibrary.Entry;
+
+/** The UI/media control boundary. PlaybackService's command methods are always local. */
+final class PlayerController implements AutoCloseable {
+    enum Action {
+        TOGGLE("toggle"), RESUME("resume"), PAUSE("pause"), STOP("stop"), NEXT("next"), PREVIOUS("previous"),
+        SEEK("seek"), MARK_A("a"), MARK_B("b"), MARKERS("markers"), LOOP("loop"), CLEAR("clear");
+        final String wire;
+        Action(String wire) { this.wire=wire; }
+    }
+    static final class Item {
+        final String name; final boolean folder;
+        private final Entry local;
+        private final BluetoothLink.Item remote;
+        Item(Entry entry) { name=entry.name; folder=entry.folder; local=entry; remote=null; }
+        Item(BluetoothLink.Item entry) { name=entry.name; folder=entry.folder; local=null; remote=entry; }
+    }
+    /** Immutable rows and capabilities; the activity renders this without knowing the endpoint. */
+    static final class Browser {
+        final String title,rootLabel;
+        final List<Item> items;
+        final boolean paged,up,previous,next,ready;
+        private final BluetoothLink.Listing listing;
+        Browser(String title,List<Item> items,boolean up) {
+            this.title=title; this.items=Collections.unmodifiableList(new ArrayList<>(items)); this.up=up;
+            rootLabel="Music folder"; paged=previous=next=false; ready=true; listing=null;
+        }
+        Browser(BluetoothLink.Listing listing,boolean ready) {
+            this.listing=listing; this.ready=ready; paged=true; rootLabel="Remote root"; up=ready && listing.up;
+            previous=ready && listing.offset>0; next=ready && listing.offset+listing.items.size()<listing.total;
+            title=listing.title+" · "+(listing.total==0 ? "0 items" : (listing.offset+1)+"–"+(listing.offset+listing.items.size())+" / "+listing.total);
+            ArrayList<Item> rows=new ArrayList<>(); for (BluetoothLink.Item item:listing.items) rows.add(new Item(item));
+            items=Collections.unmodifiableList(rows);
+        }
+        static Browser empty() { return new Browser("Select a folder on internal storage or microSD",Collections.emptyList(),false); }
+    }
+    private final PlaybackService player;
+    private final BluetoothLink bluetooth;
+    private final Handler main=new Handler(Looper.getMainLooper());
+    private final ExecutorService files=Executors.newSingleThreadExecutor();
+    private final AtomicInteger generation=new AtomicInteger();
+    private final ArrayList<String> parents=new ArrayList<>();
+    private Uri tree;
+    private String directory;
+    private boolean trackView;
+    private volatile boolean closed;
+    private volatile Browser localBrowser=Browser.empty();
+    private volatile String browserError="";
+    private Browser remoteBrowser;
+
+    PlayerController(PlaybackService player) {
+        this.player=player; bluetooth=new BluetoothLink(player);
+        String saved=player.getSharedPreferences("MainActivity",Context.MODE_PRIVATE).getString("tree",null);
+        if (saved!=null) { tree=Uri.parse(saved); directory=DocumentsContract.getTreeDocumentId(tree); listFolder(); }
+    }
+    private boolean remote() { return bluetooth.mode==BluetoothLink.CLIENT; }
+    boolean bluetoothEnabled() { return bluetooth.mode!=BluetoothLink.LOCAL; }
+    boolean available() { return !remote() || bluetooth.connected; }
+    boolean hasMusicFolder() { return tree!=null; }
+    PlaybackService.Snapshot snapshot() { return remote() ? bluetooth.remote : player.snapshot; }
+    String bluetoothStatus() { return bluetooth.status; }
+    String status() {
+        String error=!bluetooth.error.isEmpty() ? "Bluetooth: "+bluetooth.error : remote() ? "" : browserError;
+        return error.isEmpty() ? snapshot().status : error+"\n"+snapshot().status;
+    }
+    Browser browser() {
+        if (!remote()) return localBrowser;
+        BluetoothLink.Listing listing=bluetooth.listing;
+        if (remoteBrowser==null || remoteBrowser.listing!=listing || remoteBrowser.ready!=bluetooth.connected)
+            remoteBrowser=new Browser(listing,bluetooth.connected);
+        return remoteBrowser;
+    }
+    void perform(Action action) { execute(action,0,0); }
+    void seek(long position) { execute(Action.SEEK,position,0); }
+    void markers(long a,long b) { execute(Action.MARKERS,a,b); }
+    private void execute(Action action,long first,long second) {
+        if (closed) return;
+        if (remote()) {
+            switch (action) {
+                case SEEK: bluetooth.command(action.wire,"position",first); break;
+                case MARKERS: bluetooth.command(action.wire,"a",first,"b",second); break;
+                default: bluetooth.command(action.wire);
+            }
+            return;
+        }
+        switch (action) {
+            case TOGGLE: foreground(); player.toggle(); break;
+            case RESUME: foreground(); player.resumePlayback(); break;
+            case PAUSE: player.pausePlayback(); break;
+            case STOP: player.stopPlayback(); break;
+            case NEXT: foreground(); player.next(1); break;
+            case PREVIOUS: foreground(); player.next(-1); break;
+            case SEEK: player.seek(first); break;
+            case MARK_A: player.markA(); break;
+            case MARK_B: player.markB(); break;
+            case MARKERS: player.markers(first,second); break;
+            case LOOP: player.toggleLoop(); break;
+            case CLEAR: player.clearLoop(); break;
+        }
+    }
+    private void foreground() { player.startForegroundService(new Intent(player,PlaybackService.class)); }
+    void startBluetooth(int mode,String address) {
+        boolean wasRemote=remote();
+        if (mode==BluetoothLink.CLIENT) { generation.incrementAndGet(); player.pausePlayback(); }
+        foreground(); bluetooth.start(mode,address); remoteBrowser=null;
+        if (!remote() && (wasRemote || mode==BluetoothLink.CLIENT)) listFolder();
+        player.bluetoothChanged();
+    }
+    void stopBluetooth() {
+        boolean wasRemote=remote();
+        if (wasRemote) generation.incrementAndGet();
+        bluetooth.close(); remoteBrowser=null;
+        if (wasRemote) listFolder();
+        player.bluetoothStopped();
+    }
+    /** Only the activity owns the Android folder picker; destination selection stays here. */
+    void chooseFolder(Runnable picker) {
+        if (remote()) bluetooth.command("root"); else picker.run();
+    }
+    void musicFolder(Uri chosen) {
+        tree=chosen; directory=DocumentsContract.getTreeDocumentId(tree); parents.clear();
+        player.getSharedPreferences("MainActivity",Context.MODE_PRIVATE).edit().putString("tree",tree.toString()).apply();
+        listFolder();
+    }
+    void page(int direction) {
+        if (!remote() || !bluetooth.connected) return;
+        bluetooth.page(bluetooth.listing.offset+direction*BluetoothLink.PAGE_SIZE);
+    }
+    void filter(String query) { if (remote()) bluetooth.command("filter","query",query); }
+    void refresh() {
+        if (remote()) bluetooth.command("root"); else listFolder();
+    }
+    boolean up() {
+        if (remote()) {
+            if (!browser().up) return false;
+            bluetooth.command("up"); return true;
+        }
+        if (trackView) { listFolder(); return true; }
+        if (parents.isEmpty()) return false;
+        directory=parents.remove(parents.size()-1); listFolder(); return true;
+    }
+    void open(Browser view,int position) {
+        if (closed || position<0 || position>=view.items.size()) return;
+        Item item=view.items.get(position);
+        if (remote()) {
+            if (view.listing!=null && bluetooth.connected) bluetooth.open(item.remote,view.listing);
+            return;
+        }
+        if (view!=localBrowser || item.local==null) return;
+        Entry entry=item.local;
+        if (entry.folder) { parents.add(directory); directory=entry.id; listFolder(); }
+        else if (entry.track!=null) playEntry(entry,view.items);
+        else openTrackView(entry,view.items);
+    }
+    private void playEntry(Entry entry,List<Item> items) {
+        ArrayList<Track> tracks=new ArrayList<>(); int selected=0;
+        for (Item item:items) {
+            Entry candidate=item.local;
+            if (!candidate.folder && (candidate==entry || candidate.audio)) {
+                if (candidate==entry) selected=tracks.size();
+                tracks.add(candidate.track!=null ? candidate.track : new Track(candidate.uri,candidate.name));
+            }
+        }
+        foreground(); player.play(tracks,selected);
+    }
+    private boolean current(int request) { return !closed && request==generation.get() && !remote(); }
+    private void listFolder() {
+        if (closed || remote()) return;
+        if (tree==null) { localBrowser=Browser.empty(); return; }
+        final Uri selectedTree=tree; final String selectedDirectory=directory;
+        trackView=false; browserError="";
+        final int request=generation.incrementAndGet();
+        localBrowser=new Browser("Loading folder…",Collections.emptyList(),!parents.isEmpty());
+        files.submit(() -> {
+            ArrayList<Item> rows=new ArrayList<>(); String error="";
+            try { for (Entry entry:MusicLibrary.list(player,selectedTree,selectedDirectory)) rows.add(new Item(entry)); }
+            catch (Exception e) { error="Cannot read folder: "+e.getMessage()+". Choose the folder again if the card changed."; }
+            final String failure=error;
+            main.post(() -> {
+                if (!current(request)) return;
+                browserError=failure; localBrowser=new Browser(selectedDirectory+" · "+rows.size()+" items",rows,!parents.isEmpty());
+            });
+        });
+    }
+    private void openTrackView(Entry entry,List<Item> items) {
+        ArrayList<Entry> folder=new ArrayList<>(); for (Item item:items) folder.add(item.local);
+        final int request=generation.incrementAndGet(); browserError="";
+        localBrowser=new Browser("Reading track information…",items,!parents.isEmpty());
+        files.submit(() -> {
+            ArrayList<Track> found=new ArrayList<>(); String error=null;
+            try { found=MusicLibrary.tracks(player,entry,folder); }
+            catch (Exception | LinkageError e) { error=e.getMessage(); }
+            final ArrayList<Track> tracks=found; final String failure=error;
+            main.post(() -> {
+                if (!current(request)) return;
+                if (!tracks.isEmpty()) {
+                    trackView=true; ArrayList<Item> rows=new ArrayList<>();
+                    for (Track track:tracks) rows.add(new Item(new Entry(track)));
+                    localBrowser=new Browser(entry.name+" · "+tracks.size()+" tracks",rows,true);
+                } else {
+                    localBrowser=new Browser(directory+" · "+items.size()+" items",items,!parents.isEmpty());
+                    if (MusicLibrary.isCue(entry)) browserError=failure==null ? "No track information found" : failure;
+                    else playEntry(entry,items);
+                }
+            });
+        });
+    }
+    void saveBrowser(Bundle out) {
+        if (tree!=null) { out.putString("tree",tree.toString()); out.putString("directory",directory); out.putStringArrayList("parents",new ArrayList<>(parents)); }
+    }
+    void restoreBrowser(Bundle saved) {
+        if (saved==null || saved.getString("tree")==null) return;
+        Uri savedTree=Uri.parse(saved.getString("tree"));
+        String savedDirectory=saved.getString("directory",DocumentsContract.getTreeDocumentId(savedTree));
+        ArrayList<String> savedParents=saved.getStringArrayList("parents");
+        if (savedParents==null) savedParents=new ArrayList<>();
+        if (tree!=null && tree.toString().equals(savedTree.toString()) && Objects.equals(directory,savedDirectory) && parents.equals(savedParents)) return;
+        tree=savedTree; directory=savedDirectory; parents.clear(); parents.addAll(savedParents); listFolder();
+    }
+    @Override public void close() {
+        closed=true; generation.incrementAndGet(); files.shutdownNow(); bluetooth.close();
+    }
+}
diff --git a/app/src/main/res/drawable/ic_music.xml b/app/src/main/res/drawable/ic_music.xml
index c8c2f3e..320cfe4 100644
--- a/app/src/main/res/drawable/ic_music.xml
+++ b/app/src/main/res/drawable/ic_music.xml
@@ -1,3 +1,50 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
-    <path android:fillColor="#FFFFFFFF" android:pathData="M12,3v10.55A4,4 0,1 0,14 17V7h6V3z" />
-</vector>
+<?xml version='1.0' encoding='utf-8'?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="1024" android:viewportHeight="1024">
+    <group android:scaleX="1" android:scaleY="0.971952" android:translateX="2.22334" android:translateY="20.9471">
+        <path android:fillColor="#FFFAFAFA" android:fillType="evenOdd" android:pathData="M119.803,216.634h779.948v577.18h-779.948Z" />
+    </group>
+    <group android:scaleX="1" android:scaleY="1" android:translateX="5.68434e-14" android:translateY="-44.3964">
+        <group android:scaleX="0.84404" android:scaleY="0.84404" android:translateX="23.5161" android:translateY="189.953">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M264.225,412.368a27.359,27.359 0,1 0 54.718,0a27.359,27.359 0,1 0 -54.718,0Z" />
+        </group>
+        <group android:scaleX="-0.84404" android:scaleY="-0.84404" android:translateX="1000.48" android:translateY="993.402">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M264.225,412.368a27.359,27.359 0,1 0 54.718,0a27.359,27.359 0,1 0 -54.718,0Z" />
+        </group>
+        <group android:scaleX="0.84404" android:scaleY="0.84404" android:translateX="23.5161" android:translateY="297.356">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M264.225,412.368a27.359,27.359 0,1 0 54.718,0a27.359,27.359 0,1 0 -54.718,0Z" />
+        </group>
+        <group android:scaleX="-0.84404" android:scaleY="-0.84404" android:translateX="1000.48" android:translateY="885.999">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M264.225,412.368a27.359,27.359 0,1 0 54.718,0a27.359,27.359 0,1 0 -54.718,0Z" />
+        </group>
+        <group android:scaleX="1.05929" android:scaleY="0.84404" android:translateX="-27.0837" android:translateY="177.695">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M209.699,229.481h598.426v13.073h-598.426Z" />
+        </group>
+        <group android:scaleX="1.05929" android:scaleY="0.84404" android:translateX="-27.0837" android:translateY="285.098">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M209.699,229.481h598.426v13.073h-598.426Z" />
+        </group>
+        <group android:scaleX="1.05929" android:scaleY="0.84404" android:translateX="-27.0837" android:translateY="392.501">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M209.699,229.481h598.426v13.073h-598.426Z" />
+        </group>
+        <group android:scaleX="1.05929" android:scaleY="0.84404" android:translateX="-27.0837" android:translateY="499.904">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M209.699,229.481h598.426v13.073h-598.426Z" />
+        </group>
+        <group android:scaleX="1.05929" android:scaleY="0.84404" android:translateX="-27.0837" android:translateY="607.307">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M209.699,229.481h598.426v13.073h-598.426Z" />
+        </group>
+        <group android:scaleX="1.28391" android:scaleY="0.769702" android:translateX="-7.58511" android:translateY="207.176">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M126.361,212.401h31.463v572.572h-31.463Z" />
+        </group>
+        <group android:scaleX="-1.28391" android:scaleY="-0.769702" android:translateX="1031.59" android:translateY="976.179">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M126.361,212.401h31.463v572.572h-31.463Z" />
+        </group>
+        <group android:scaleX="0.397354" android:scaleY="0.769702" android:translateX="164.032" android:translateY="207.837">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M126.361,212.401h31.463v572.572h-31.463Z" />
+        </group>
+        <group android:scaleX="-0.397354" android:scaleY="-0.769702" android:translateX="859.968" android:translateY="975.518">
+            <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M126.361,212.401h31.463v572.572h-31.463Z" />
+        </group>
+    </group>
+    <group android:scaleX="1" android:scaleY="1.10691" android:translateX="-22.8177" android:translateY="-78.2581">
+        <path android:fillColor="#FF000000" android:fillType="evenOdd" android:pathData="M496.029,662.18C496.029,687.581 464.553,708.203 425.783,708.203C387.013,708.203 355.537,687.581 355.537,662.18C355.537,636.78 387.013,616.158 425.783,616.158C442.172,616.158 457.258,619.843 469.214,626.017L469.214,301.706L496.029,301.706L496.029,662.18Z" />
+    </group>
+</vector>
\ No newline at end of file
diff --git a/scripts/test-bluetooth.py b/scripts/test-bluetooth.py
index ba29f89..4b6b925 100644
--- a/scripts/test-bluetooth.py
+++ b/scripts/test-bluetooth.py
@@ -18,14 +18,16 @@ with tempfile.TemporaryDirectory(prefix='loopdeck-bluetooth-') as temp:
  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 Handler(Looper l){} public void post(Runnable r){r.run();} 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/Bundle','final java.util.Map<String,Object> values=new java.util.HashMap<>();public void putString(String k,String v){values.put(k,v);} public String getString(String k){return (String)values.get(k);} public String getString(String k,String fallback){return (String)values.getOrDefault(k,fallback);} public void putStringArrayList(String k,java.util.ArrayList<String> v){values.put(k,new java.util.ArrayList<>(v));} @SuppressWarnings("unchecked") public java.util.ArrayList<String> getStringArrayList(String k){return (java.util.ArrayList<String>)values.get(k);}')
+ stub('android/content/Intent','public Intent(Context c,Class<?> target){}')
  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/Context','public static final String BLUETOOTH_SERVICE="bluetooth"; public static final int MODE_PRIVATE=0; public Object getSystemService(String n){return null;} public void startForegroundService(Intent i){} 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";} public Editor edit(){return new Editor();} public static class Editor {public Editor putString(String k,String v){return this;} public void apply(){}}')
  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<>();
@@ -76,7 +78,8 @@ with tempfile.TemporaryDirectory(prefix='loopdeck-bluetooth-') as temp:
  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 volatile int openCount; static volatile java.util.concurrent.CountDownLatch entered,release;
+ static long open(int fd){openCount++;if(entered!=null){entered.countDown();try{release.await();}catch(InterruptedException e){Thread.currentThread().interrupt();}}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','''
@@ -91,7 +94,7 @@ with tempfile.TemporaryDirectory(prefix='loopdeck-bluetooth-') as temp:
  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 bluetoothChanged(){} void bluetoothStopped(){}
  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");}
@@ -151,12 +154,56 @@ public final class BluetoothTest {
    await(()->server.error.contains("Incompatible"));invalid.close();
    client.start(BluetoothLink.CLIENT,"fiio");await(()->client.connected && client.listing.total==128);
    expect(NativeDecoder.openCount==0);
+   client.close();await(()->!server.connected);
+   // The same controller API drives local and remote browsing/playback.
+   PlaybackService controlled=new PlaybackService("controller-phone");
+   PlayerController control=new PlayerController(controlled);
+   try {
+    await(()->control.browser().items.size()==128);
+    expect(!control.browser().paged && control.available());
+    int[] pickerCalls={0};control.chooseFolder(()->pickerCalls[0]++);expect(pickerCalls[0]==1);
+    for(PlayerController.Action action:PlayerController.Action.values())
+     if(action!=PlayerController.Action.SEEK && action!=PlayerController.Action.MARKERS)control.perform(action);
+    control.seek(789);control.markers(100,500);
+    expect(controlled.calls.containsAll(List.of("toggle","resume","pause","stop","next","previous","a","b","loop","clear","seek:789","markers:100:500")));
+    control.open(control.browser(),0);await(()->control.browser().items.size()==2);
+    control.open(control.browser(),0);await(()->!control.browser().items.isEmpty() && control.browser().items.get(0).name.equals("1. One"));
+    android.os.Bundle saved=new android.os.Bundle();control.saveBrowser(saved);PlayerController.Browser cueView=control.browser();
+    control.restoreBrowser(saved);expect(control.browser()==cueView); // Rotation preserves the current CUE view.
+    control.open(cueView,1);expect(controlled.queue.size()==2 && controlled.queue.get(1).startMs==60000);
+    expect(control.up());await(()->!control.browser().items.isEmpty() && control.browser().items.get(0).name.equals("album.cue"));
+    expect(control.up());await(()->control.browser().items.size()==128);
+    // An in-flight local metadata read cannot start local playback after switching to Client.
+    NativeDecoder.entered=new java.util.concurrent.CountDownLatch(1);NativeDecoder.release=new java.util.concurrent.CountDownLatch(1);
+    control.open(control.browser(),1);expect(NativeDecoder.entered.await(2,java.util.concurrent.TimeUnit.SECONDS));
+    control.startBluetooth(BluetoothLink.CLIENT,"fiio");NativeDecoder.release.countDown();
+    await(()->control.browser().paged && control.browser().ready && control.browser().items.size()==50);
+    await(()->NativeDecoder.openCount==0);NativeDecoder.entered=null;NativeDecoder.release=null;
+    expect(controlled.calls.stream().filter("play"::equals).count()==1);
+    int localCalls=controlled.calls.size();serverPlayer.calls.clear();
+    for(PlayerController.Action action:PlayerController.Action.values())
+     if(action!=PlayerController.Action.SEEK && action!=PlayerController.Action.MARKERS)control.perform(action);
+    control.seek(987);control.markers(200,900);
+    await(()->serverPlayer.calls.contains("markers:200:900"));
+    expect(serverPlayer.calls.containsAll(List.of("toggle","resume","pause","stop","next","previous","a","b","loop","clear","seek:987")));
+    expect(controlled.calls.size()==localCalls); // No remote action leaks into the local playback engine.
+    await(()->control.snapshot().title.equals("日本語🎵"));
+    control.page(1);await(()->control.browser().previous);expect(control.browser().items.get(0).name.contains("049"));
+    control.filter("126");await(()->control.browser().items.size()==1);
+    control.chooseFolder(()->pickerCalls[0]++);await(()->control.browser().items.size()==50);expect(pickerCalls[0]==1);
+    control.open(control.browser(),0);await(()->control.browser().items.size()==2);
+    expect(control.up());await(()->control.browser().items.size()==50);
+    control.refresh();await(()->control.browser().items.size()==50);
+    control.stopBluetooth();await(()->!control.browser().paged && control.browser().items.size()==128);
+    control.perform(PlayerController.Action.TOGGLE);expect(controlled.calls.get(controlled.calls.size()-1).equals("toggle"));
+   } finally {control.close();}
+
   }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");
+  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, malformed peer recovery, controller routing/local+remote navigation and cancellation on mode switch 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']]
+ sources=list(root.rglob('*.java'))+[app/(name+'.java') for name in ['BluetoothLink','RemoteWire','MusicLibrary','Track','CueSheet','PlayerController']]
  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 6377105..7fc453a 100644
--- a/scripts/test-playback-handoff.py
+++ b/scripts/test-playback-handoff.py
@@ -38,7 +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;}',
+'net/astropenguin/loopdeck/PlayerController':'enum Action{RESUME,PAUSE,STOP,NEXT,PREVIOUS,TOGGLE} final PlaybackService player; boolean enabled; PlayerController(PlaybackService p){player=p;} PlaybackService.Snapshot snapshot(){return player.snapshot;} void perform(Action a){} void seek(long p){} boolean bluetoothEnabled(){return enabled;} String bluetoothStatus(){return "test";} void stopBluetooth(){} void close(){}',
 }
 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 ''
@@ -79,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  // 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')
+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  // Stop keeps a controller-owned server alive; ending that session releases it.\n  s=create(List.of(queue.get(0)));s.controller=new PlayerController(s);s.controller.enabled=true;\n  s.stopPlayback();Object stopCommand=((java.util.Queue<?>)get(s,"commands")).poll();call(stopCommand,"run");call(s,"publish");\n  expect(s.stops==0 && s.controller.bluetoothEnabled());\n  s.controller.enabled=false;s.bluetoothStopped();expect(s.stops==1);\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'