penguin/loopdeck

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

commit f5939b186b1b83b98ca9028ab449855ada9899fc

author斟酌 鵬兄 <tgckpg@gmail.com>
date2026-10-24T07:33:37Z
subjectAdded volumn controls & battery status
commit f5939b186b1b83b98ca9028ab449855ada9899fc
Author: 斟酌 鵬兄 <tgckpg@gmail.com>
Date:   2026-10-24T07:33:37Z

    Added volumn controls & battery status
---
 README.md                                          | 13 +++++++---
 VALIDATION.md                                      | 28 ++++++++++++++++++++
 app/src/main/AndroidManifest.xml                   |  1 +
 .../net/astropenguin/loopdeck/BluetoothLink.java   | 18 ++++++++-----
 .../net/astropenguin/loopdeck/DeviceStatus.java    | 14 ++++++++++
 .../net/astropenguin/loopdeck/MainActivity.java    | 17 ++++++++++++
 .../net/astropenguin/loopdeck/PlaybackService.java | 21 +++++++++++++++
 .../astropenguin/loopdeck/PlayerController.java    |  6 ++++-
 scripts/test-bluetooth.py                          | 30 +++++++++++++++++-----
 scripts/test-playback-handoff.py                   |  9 ++++---
 10 files changed, 136 insertions(+), 21 deletions(-)

diff --git a/README.md b/README.md
index b2388cb..bb60892 100644
--- a/README.md
+++ b/README.md
@@ -231,10 +231,15 @@ runs the A–B loop itself.
    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,
+5. The client shows the server battery percentage and charging status below the
+   Bluetooth connection. **Volume − / +** adjust the FiiO media volume one step
+   per tap; the middle label shows its current level and maximum. The same buttons
+   control this device in Local/Server mode, including while playback is stopped.
+   Update both devices for battery and volume support.
+6. 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,
+7. **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
@@ -252,7 +257,9 @@ 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.
+32 messages. Position-only updates are limited to once per two seconds; changes
+to playback, battery or volume bypass that throttle. Unchanged state is not resent.
+Battery readings are cached from Android broadcasts; no battery polling is added.
 Audio decoding and Bluetooth/file IO run on separate workers.
 
 Host integration test (JDK 17+, test-only `org.json` jar):
diff --git a/VALIDATION.md b/VALIDATION.md
index 751cd53..4574e6f 100644
--- a/VALIDATION.md
+++ b/VALIDATION.md
@@ -219,3 +219,31 @@ real underrun recovery require hardware measurements. Compare the same files
 with Server enabled/disabled and collect `adb logcat -s LoopDeck:W` if a gap recurs.
 A one-second output queue cannot hide stalls longer than the audio queued at the
 moment they occur.
+
+
+## Server battery and volume controls (base ae2a86b)
+
+- Client displays server battery percentage and charging status. Battery state is
+  cached from the sticky Android battery broadcast and cleared with remote state
+  on disconnect. Unknown readings are distinct from an empty battery.
+- Volume buttons route through PlayerController and adjust the audio-producing
+  device's Android media stream by one step. The UI displays current/max levels,
+  disables at the limits or while disconnected, and works without a loaded track.
+  Hardware volume changes are picked up by the existing server state check.
+- Battery/volume fields extend existing state messages with optional defaults for
+  older servers; unchanged state is still suppressed, and position-only updates
+  retain their two-second throttle. No additional Bluetooth timer was added.
+- Bluetooth host integration test passed, covering battery/charging changes,
+  unknown battery, volume updates and limits, local/remote controller routing,
+  remote-state cleanup, and the existing protocol/browsing/playback cases.
+- Actual PlaybackService host tests passed, including battery scale conversion,
+  zero/unknown values, charging/full status, media volume adjustment and limits,
+  plus the existing playback handoff tests.
+- All production Java sources compiled against Android API 35 using a placeholder
+  for the generated R drawable constant. Deprecation warnings only.
+- Manifest XML parsing, git diff --check and patch applicability passed.
+
+No APK was built. Install the rebuilt app on both FiiO and phone and check battery
+updates when plugging/unplugging the player, volume changes from either device,
+volume limits, and disconnect/reconnect. Real FiiO volume/DAC behavior and UI
+layout remain hardware checks.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0e68713..ead52ac 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,7 @@
 <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" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
     <!-- 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" />
diff --git a/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java b/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
index 03c3b53..46533ac 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/BluetoothLink.java
@@ -29,6 +29,7 @@ final class BluetoothLink implements AutoCloseable {
     volatile boolean connected;
     volatile String status="Bluetooth off", error="";
     volatile PlaybackService.Snapshot remote=emptySnapshot();
+    volatile DeviceStatus remoteDevice=DeviceStatus.UNKNOWN;
     private long remoteUpdatedAt;
     volatile Listing listing=new Listing(0,"Connect to a player",false,0,0,Collections.emptyList());
 
@@ -63,7 +64,7 @@ final class BluetoothLink implements AutoCloseable {
             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());
+            remote=emptySnapshot(); remoteDevice=DeviceStatus.UNKNOWN; 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(); }
@@ -116,7 +117,7 @@ final class BluetoothLink implements AutoCloseable {
             active.close();
             synchronized (this) {
                 if (peer==active) {
-                    peer=null; connected=false; remote=emptySnapshot();
+                    peer=null; connected=false; remote=emptySnapshot(); remoteDevice=DeviceStatus.UNKNOWN;
                     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();
@@ -150,7 +151,7 @@ final class BluetoothLink implements AutoCloseable {
         ++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());
+        remote=emptySnapshot(); remoteDevice=DeviceStatus.UNKNOWN; 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) {} }
@@ -208,7 +209,7 @@ final class BluetoothLink implements AutoCloseable {
                     if (value!=null) RemoteWire.write(output,value);
                     long now=android.os.SystemClock.elapsedRealtime();
                     if (role==SERVER && ready) {
-                        JSONObject update=state(player.snapshot);
+                        JSONObject update=state(player.snapshot,player.deviceStatus());
                         String state=update.toString();
                         update.remove("position");
                         String details=update.toString();
@@ -229,6 +230,8 @@ final class BluetoothLink implements AutoCloseable {
                     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"));
+                    remoteDevice=new DeviceStatus(value.optInt("battery",-1),value.optBoolean("charging",false),
+                        value.optInt("volume",-1),value.optInt("maxVolume",0));
                     remoteUpdatedAt=android.os.SystemClock.elapsedRealtime();
                     if (!remote.title.equals(previous.title) || remote.playing!=previous.playing || remote.loaded!=previous.loaded || remote.duration!=previous.duration)
                         player.bluetoothChanged();
@@ -284,6 +287,8 @@ final class BluetoothLink implements AutoCloseable {
                 case "b": player.markB(); break;
                 case "loop": player.toggleLoop(); break;
                 case "clear": player.clearLoop(); break;
+                case "volumeDown": player.adjustVolume(-1); break;
+                case "volumeUp": player.adjustVolume(1); break;
                 case "markers": player.markers(milliseconds(value,"a",true),milliseconds(value,"b",true)); break;
                 default: throw new IOException("Unknown Bluetooth command");
             }
@@ -328,8 +333,9 @@ final class BluetoothLink implements AutoCloseable {
         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) {
+    private static JSONObject state(PlaybackService.Snapshot s,DeviceStatus device) {
         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);
+            "a",s.a,"b",s.b,"playing",s.playing,"loop",s.loop,"loaded",s.loaded,
+            "battery",device.battery,"charging",device.charging,"volume",device.volume,"maxVolume",device.maxVolume);
     }
 }
diff --git a/app/src/main/java/net/astropenguin/loopdeck/DeviceStatus.java b/app/src/main/java/net/astropenguin/loopdeck/DeviceStatus.java
new file mode 100644
index 0000000..ee62eef
--- /dev/null
+++ b/app/src/main/java/net/astropenguin/loopdeck/DeviceStatus.java
@@ -0,0 +1,14 @@
+package net.astropenguin.loopdeck;
+
+/** Status of the device producing audio; unknown values remain distinct from zero. */
+final class DeviceStatus {
+    static final DeviceStatus UNKNOWN=new DeviceStatus(-1,false,-1,0);
+    final int battery,volume,maxVolume;
+    final boolean charging;
+    DeviceStatus(int battery,boolean charging,int volume,int maxVolume) {
+        this.battery=battery>=0 && battery<=100 ? battery : -1;
+        this.charging=charging;
+        this.maxVolume=Math.max(0,maxVolume);
+        this.volume=volume>=0 && volume<=this.maxVolume ? volume : -1;
+    }
+}
diff --git a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
index db44ba5..1d7d5f6 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/MainActivity.java
@@ -28,6 +28,8 @@ public final class MainActivity extends Activity {
     private int pendingMode;
     private Button play, setA, setB, loopButton, clear, previous, next, stop;
     private SeekBar seek;
+    private TextView batteryLabel,volumeLabel;
+    private Button volumeDown,volumeUp;
     private EntryAdapter adapter;
 
     private final ServiceConnection connection=new ServiceConnection() {
@@ -71,6 +73,7 @@ public final class MainActivity extends Activity {
         LinearLayout bluetoothRow=row();
         bluetoothButton=button(bluetoothRow,"Bluetooth: off",v -> bluetoothMenu());
         root.addView(bluetoothRow);
+        batteryLabel=text("",12,MUTED); batteryLabel.setVisibility(View.GONE); root.addView(batteryLabel);
         remotePages=row();
         pageBack=button(remotePages,"‹ Page",v -> { if (controller!=null) controller.page(-1); });
         find=button(remotePages,"Find / refresh",v -> remoteSearch());
@@ -103,6 +106,13 @@ public final class MainActivity extends Activity {
         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 volume=row();
+        volumeDown=button(volume,"Volume −",v -> control(VOLUME_DOWN));
+        volumeDown.setContentDescription("Decrease player volume");
+        volumeLabel=text("Volume —",12,TEXT); volumeLabel.setGravity(Gravity.CENTER);
+        volume.addView(volumeLabel,new LinearLayout.LayoutParams(0,dp(48),1));
+        volumeUp=button(volume,"Volume +",v -> control(VOLUME_UP));
+        volumeUp.setContentDescription("Increase player volume"); root.addView(volume);
         LinearLayout markers=row();
         setA=button(markers,"Set A",v -> control(MARK_A));
         setB=button(markers,"Set B",v -> control(MARK_B));
@@ -148,6 +158,13 @@ public final class MainActivity extends Activity {
             if (seek.getProgress()!=value) seek.setProgress(value);
         }
         boolean available=controller.available();
+        DeviceStatus device=controller.deviceStatus();
+        batteryLabel.setVisibility(browser.paged ? View.VISIBLE : View.GONE);
+        replace(batteryLabel,!available ? "Server battery · disconnected" : "Server battery · "+
+            (device.battery<0 ? "unavailable" : device.battery+"%")+(device.charging ? " · charging" : ""));
+        replace(volumeLabel,device.volume<0 ? "Volume —" : "Volume "+device.volume+" / "+device.maxVolume);
+        volumeDown.setEnabled(available && device.volume>0);
+        volumeUp.setEnabled(available && device.volume>=0 && device.volume<device.maxVolume);
         previous.setEnabled(available); next.setEnabled(available);
         seek.setEnabled(available && s.loaded && s.duration>0);
         play.setEnabled(available && s.loaded); stop.setEnabled(available && s.loaded);
diff --git a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
index 09d4149..23b2c98 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlaybackService.java
@@ -27,6 +27,7 @@ public final class PlaybackService extends Service {
     private Thread worker;
     PlayerController controller;
     private AudioManager audioManager;
+    private volatile DeviceStatus battery=DeviceStatus.UNKNOWN;
     private AudioFocusRequest focus;
     private PowerManager.WakeLock wake;
     private MediaSession session;
@@ -96,10 +97,29 @@ public final class PlaybackService extends Service {
             @Override public void onSeekTo(long pos) { controller.seek(pos); }
         });
         session.setActive(true);
+        registerReceiver(batteryChanged,new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
         registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
         worker = new Thread(this::work, "LoopDeck playback");
         worker.start();
     }
+    private final BroadcastReceiver batteryChanged=new BroadcastReceiver() {
+        @Override public void onReceive(Context c,Intent i) {
+            int level=i.getIntExtra(BatteryManager.EXTRA_LEVEL,-1);
+            int scale=i.getIntExtra(BatteryManager.EXTRA_SCALE,-1);
+            int state=i.getIntExtra(BatteryManager.EXTRA_STATUS,-1);
+            battery=new DeviceStatus(level>=0 && scale>0 ? (int)(level*100L/scale) : -1,
+                state==BatteryManager.BATTERY_STATUS_CHARGING || state==BatteryManager.BATTERY_STATUS_FULL,-1,0);
+        }
+    };
+    DeviceStatus deviceStatus() {
+        DeviceStatus b=battery;
+        return new DeviceStatus(b.battery,b.charging,audioManager.getStreamVolume(AudioManager.STREAM_MUSIC),
+            audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC));
+    }
+    void adjustVolume(int direction) {
+        audioManager.adjustStreamVolume(AudioManager.STREAM_MUSIC,
+            direction>0 ? AudioManager.ADJUST_RAISE : AudioManager.ADJUST_LOWER,0);
+    }
     private final BroadcastReceiver noisy = new BroadcastReceiver() {
         @Override public void onReceive(Context c, Intent i) { submit(() -> { resumeOnFocus=false; setPlaying(false); }); }
     };
@@ -481,6 +501,7 @@ public final class PlaybackService extends Service {
     }
     @Override public void onDestroy() {
         if (controller!=null) controller.close();
+        unregisterReceiver(batteryChanged);
         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
index cdb85e0..c411dcc 100644
--- a/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
+++ b/app/src/main/java/net/astropenguin/loopdeck/PlayerController.java
@@ -17,7 +17,8 @@ import net.astropenguin.loopdeck.MusicLibrary.Entry;
 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");
+        SEEK("seek"), MARK_A("a"), MARK_B("b"), MARKERS("markers"), LOOP("loop"), CLEAR("clear"),
+        VOLUME_DOWN("volumeDown"), VOLUME_UP("volumeUp");
         final String wire;
         Action(String wire) { this.wire=wire; }
     }
@@ -71,6 +72,7 @@ final class PlayerController implements AutoCloseable {
     boolean available() { return !remote() || bluetooth.connected; }
     boolean hasMusicFolder() { return tree!=null; }
     PlaybackService.Snapshot snapshot() { return remote() ? bluetooth.remoteSnapshot() : player.snapshot; }
+    DeviceStatus deviceStatus() { return remote() ? bluetooth.remoteDevice : player.deviceStatus(); }
     String bluetoothStatus() { return bluetooth.status; }
     String status() {
         String error=!bluetooth.error.isEmpty() ? "Bluetooth: "+bluetooth.error : remote() ? "" : browserError;
@@ -109,6 +111,8 @@ final class PlayerController implements AutoCloseable {
             case MARKERS: player.markers(first,second); break;
             case LOOP: player.toggleLoop(); break;
             case CLEAR: player.clearLoop(); break;
+            case VOLUME_DOWN: player.adjustVolume(-1); break;
+            case VOLUME_UP: player.adjustVolume(1); break;
         }
     }
     private void foreground() { player.startForegroundService(new Intent(player,PlaybackService.class)); }
diff --git a/scripts/test-bluetooth.py b/scripts/test-bluetooth.py
index 9428663..174aeeb 100644
--- a/scripts/test-bluetooth.py
+++ b/scripts/test-bluetooth.py
@@ -94,6 +94,9 @@ 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");}
+ volatile DeviceStatus device=new DeviceStatus(73,false,5,10);
+ DeviceStatus deviceStatus(){return device;}
+ void adjustVolume(int d){calls.add(d>0?"volumeUp":"volumeDown");device=new DeviceStatus(device.battery,device.charging,Math.max(0,Math.min(device.maxVolume,device.volume+d)),device.maxVolume);}
  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");}
@@ -123,6 +126,17 @@ public final class BluetoothTest {
    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);
+   await(()->client.remoteDevice.battery==73 && client.remoteDevice.volume==5);
+   serverPlayer.device=new DeviceStatus(72,true,7,10);
+   await(()->client.remoteDevice.battery==72 && client.remoteDevice.charging && client.remoteDevice.volume==7);
+   client.command("volumeDown");await(()->client.remoteDevice.volume==6);
+   client.command("volumeUp");await(()->client.remoteDevice.volume==7);
+   serverPlayer.device=new DeviceStatus(-1,false,0,10);
+   await(()->client.remoteDevice.battery==-1 && client.remoteDevice.volume==0);
+   client.command("volumeDown");await(()->serverPlayer.calls.contains("volumeDown"));
+   expect(serverPlayer.device.volume==0);
+   serverPlayer.device=new DeviceStatus(72,false,10,10);
+   client.command("volumeUp");await(()->client.remoteDevice.volume==10);
    // A steadily advancing position produces at most one correction per two seconds.
    PlaybackService.Snapshot seen=client.remote;int corrections=0;
    long started=android.os.SystemClock.elapsedRealtime();
@@ -168,27 +182,28 @@ public final class BluetoothTest {
    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.close();await(()->!server.connected);expect(client.remoteDevice==DeviceStatus.UNKNOWN);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);
+   client.close();await(()->!server.connected);expect(client.remoteDevice==DeviceStatus.UNKNOWN);
    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);
-   client.close();await(()->!server.connected);
+   client.close();await(()->!server.connected);expect(client.remoteDevice==DeviceStatus.UNKNOWN);
    // 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());
+    expect(control.deviceStatus().battery==73 && control.deviceStatus().volume==5);
     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")));
+    expect(controlled.calls.containsAll(List.of("toggle","resume","pause","stop","next","previous","a","b","loop","clear","volumeDown","volumeUp","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();
@@ -203,12 +218,13 @@ public final class BluetoothTest {
     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);
+    await(()->control.deviceStatus().battery==72 && control.deviceStatus().volume==10);
     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(serverPlayer.calls.containsAll(List.of("toggle","resume","pause","stop","next","previous","a","b","loop","clear","volumeDown","volumeUp","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"));
@@ -217,7 +233,7 @@ public final class BluetoothTest {
     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.stopBluetooth();expect(control.deviceStatus().battery==73);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();}
 
@@ -227,6 +243,6 @@ public final class BluetoothTest {
  }
 }''')
  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','PlayerController']]
+ sources=list(root.rglob('*.java'))+[app/(name+'.java') for name in ['BluetoothLink','RemoteWire','MusicLibrary','Track','CueSheet','PlayerController','DeviceStatus']]
  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 96bbc2f..a09f6c5 100644
--- a/scripts/test-playback-handoff.py
+++ b/scripts/test-playback-handoff.py
@@ -12,8 +12,9 @@ def put(name,text):
 classes={
 'android/content/Context':'public static final String AUDIO_SERVICE="audio",POWER_SERVICE="power",NOTIFICATION_SERVICE="notification"; public Object getSystemService(String s){ return s.equals(AUDIO_SERVICE)?new android.media.AudioManager():s.equals(POWER_SERVICE)?new android.os.PowerManager():new android.app.NotificationManager(); } public ContentResolver getContentResolver(){return new ContentResolver();} public void registerReceiver(BroadcastReceiver r,IntentFilter f){} public void unregisterReceiver(BroadcastReceiver r){}',
 'android/content/ContentResolver':'public android.os.ParcelFileDescriptor openFileDescriptor(android.net.Uri u,String mode){return new android.os.ParcelFileDescriptor(u.id);}',
-'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/Intent':'public static final String ACTION_BATTERY_CHANGED="battery"; final java.util.Map<String,Integer> extras=new java.util.HashMap<>(); public Intent putExtra(String k,int v){extras.put(k,v);return this;} public int getIntExtra(String k,int d){return extras.getOrDefault(k,d);} 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/os/BatteryManager':'public static final String EXTRA_LEVEL="level",EXTRA_SCALE="scale",EXTRA_STATUS="status"; public static final int BATTERY_STATUS_CHARGING=2,BATTERY_STATUS_FULL=5;',
 '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 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){}',
@@ -29,7 +30,7 @@ classes={
 'android/os/SystemClock':'public static long elapsedRealtime(){return System.nanoTime()/1000000;}',
 'android/os/ParcelFileDescriptor':'int fd; public ParcelFileDescriptor(int id){fd=id;} public int getFd(){return fd;} public void close(){}',
 'android/net/Uri':'public final int id; public Uri(int id){this.id=id;}',
-'android/media/AudioManager':'public static final int AUDIOFOCUS_GAIN=1,AUDIOFOCUS_REQUEST_GRANTED=1,AUDIOFOCUS_LOSS=-1,AUDIOFOCUS_LOSS_TRANSIENT=-2,AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK=-3; public static final String ACTION_AUDIO_BECOMING_NOISY="noisy"; public int requestAudioFocus(AudioFocusRequest f){return 1;} public void abandonAudioFocusRequest(AudioFocusRequest f){}',
+'android/media/AudioManager':'public static final int STREAM_MUSIC=3,ADJUST_RAISE=1,ADJUST_LOWER=-1; public int volume=5; public int getStreamVolume(int stream){return volume;} public int getStreamMaxVolume(int stream){return 10;} public void adjustStreamVolume(int stream,int d,int flags){volume=Math.max(0,Math.min(10,volume+d));} public static final int AUDIOFOCUS_GAIN=1,AUDIOFOCUS_REQUEST_GRANTED=1,AUDIOFOCUS_LOSS=-1,AUDIOFOCUS_LOSS_TRANSIENT=-2,AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK=-3; public static final String ACTION_AUDIO_BECOMING_NOISY="noisy"; public int requestAudioFocus(AudioFocusRequest f){return 1;} public void abandonAudioFocusRequest(AudioFocusRequest f){}',
 'android/media/AudioFocusRequest':'public static class Builder { public Builder(int i){} public Builder setAudioAttributes(AudioAttributes a){return this;} public Builder setOnAudioFocusChangeListener(java.util.function.IntConsumer c,android.os.Handler h){return this;} public AudioFocusRequest build(){return new AudioFocusRequest();}}',
 'android/media/AudioAttributes':'public static final int USAGE_MEDIA=1,CONTENT_TYPE_MUSIC=1; public static class Builder{ public Builder setUsage(int i){return this;} public Builder setContentType(int i){return this;} public AudioAttributes build(){return new AudioAttributes();}}',
 'android/media/AudioFormat':'public static final int CHANNEL_OUT_STEREO=2,ENCODING_PCM_FLOAT=4; public static class Builder{public Builder setSampleRate(int i){return this;} public Builder setEncoding(int i){return this;} public Builder setChannelMask(int i){return this;} public AudioFormat build(){return new AudioFormat();}}',
@@ -80,10 +81,10 @@ 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 && ((AudioTrack)get(s,"output")).capacity==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 && ((AudioTrack)get(s,"output")).capacity==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')
+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);expect(s.deviceStatus().battery==-1 && s.deviceStatus().volume==5);s.adjustVolume(1);expect(s.deviceStatus().volume==6);s.adjustVolume(-1);expect(s.deviceStatus().volume==5);\n  android.content.BroadcastReceiver battery=(android.content.BroadcastReceiver)get(s,"batteryChanged");\n  android.content.Intent info=new android.content.Intent(s,PlaybackService.class).putExtra(BatteryManager.EXTRA_LEVEL,146).putExtra(BatteryManager.EXTRA_SCALE,200).putExtra(BatteryManager.EXTRA_STATUS,BatteryManager.BATTERY_STATUS_CHARGING);\n  battery.onReceive(s,info);expect(s.deviceStatus().battery==73 && s.deviceStatus().charging);\n  info.putExtra(BatteryManager.EXTRA_STATUS,BatteryManager.BATTERY_STATUS_FULL);battery.onReceive(s,info);expect(s.deviceStatus().charging);\n  info.putExtra(BatteryManager.EXTRA_LEVEL,0).putExtra(BatteryManager.EXTRA_STATUS,3);battery.onReceive(s,info);expect(s.deviceStatus().battery==0 && !s.deviceStatus().charging);\n  info.putExtra(BatteryManager.EXTRA_SCALE,0);battery.onReceive(s,info);expect(s.deviceStatus().battery==-1);\n  for(int i=0;i<20;i++)s.adjustVolume(1);expect(s.deviceStatus().volume==10);\n  for(int i=0;i<20;i++)s.adjustVolume(-1);expect(s.deviceStatus().volume==0);\n  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 && ((AudioTrack)get(s,"output")).capacity==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 && ((AudioTrack)get(s,"output")).capacity==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'
-files=list(root.rglob('*.java'))+[app/(name+'.java') for name in ('PlaybackService','Track','LoopMath','PcmPrebuffer','PrefetchSlot')]
+files=list(root.rglob('*.java'))+[app/(name+'.java') for name in ('PlaybackService','Track','LoopMath','PcmPrebuffer','PrefetchSlot','DeviceStatus')]
 subprocess.run(['java','-m','jdk.compiler/com.sun.tools.javac.Main','-d',str(classes),*map(str,files)],check=True)
 subprocess.run(['java','-cp',str(classes),'net.astropenguin.loopdeck.PlaybackHandoffTest'],check=True)