discstation 0.1.29 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,20 +22,31 @@
22
22
  #define OLED_RESET -1
23
23
  #define I2C_ADDRESS 0x3C
24
24
 
25
- #define BTN_SELECT_PIN 14
26
- #define BTN_UP_PIN 13
27
- #define BTN_DOWN_PIN 15
28
- #define POT_PIN 34
25
+ #define BTN_EJECT_PIN 14 // was SELECT
26
+ #define BTN_HOME_PIN 13 // was UP; also carries the 10s Wi-Fi-reset hold
27
+ #define BTN_PLAYPAUSE_PIN 15 // was DOWN
28
+ #define ENC_CLK_PIN 32 // freed ex-LED pin
29
+ #define ENC_DT_PIN 33 // freed ex-LED pin
30
+ #define ENC_SW_PIN 4 // encoder's click switch
29
31
 
30
32
  #define DEBOUNCE_MS 50
31
33
  #define LONG_PRESS_MS 1000
32
- #define POT_READ_MS 200
34
+ #define ENC_CLICK_GUARD_MS 200 // ignore new SW presses this soon after a rotation tick (rotation vibration can bounce SW low)
33
35
  #define DONE_RESET_MS 30000
34
36
  #define STANDBY_BLANK_MS 60000
35
- #define IDLE_BLANK_MS 45000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
37
+ #define IDLE_BLANK_MS 15000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
38
+ // (this power bank's no-load auto-shutoff trips at ~30s idle; keep this
39
+ // well under that so the screensaver's current draw beats it there)
36
40
  #define SAVER_FRAME_MS 90 // screensaver frame interval (~11fps)
37
41
  #define PING_TIMEOUT_MS 30000
38
42
 
43
+ #define VU_BARS 16 // must match the host's VU: band count
44
+ #define VU_TIMEOUT_MS 1200 // no VU: update this long -> host isn't sending (paused/stopped), fall back to text
45
+ // (generous on purpose: the host's capture thread can jitter under load on a Pi -
46
+ // too tight and normal jitter flickers between bars and the text screen)
47
+ #define VU_ENTRY_DELAY_MS 10000 // stay on the text status screen this long after entering PLAY
48
+ #define VU_RESUME_DELAY_MS 7000 // ...and this long after any input while already in PLAY
49
+
39
50
  #define WIFI_RESET_HOLD_MS 10000 // hold SELECT this long on HOME to wipe Wi-Fi creds
40
51
  #define WIFI_CONNECT_TIMEOUT_MS 18000 // give a stored-creds join this long before falling to the portal
41
52
  #define WIFI_RETRY_MS 15000 // if a live link drops, force a re-join after this
@@ -81,6 +92,7 @@ private:
81
92
 
82
93
  void drawSetup(); // fwd decls (defined with the other draw* below)
83
94
  void drawWifiReset();
95
+ void drawPlayVisualizer();
84
96
 
85
97
  String deviceSuffix() {
86
98
  uint8_t mac[6];
@@ -218,32 +230,32 @@ bool displayOk = false;
218
230
  unsigned long returnToHomeAt = 0;
219
231
  unsigned long playStatusAt = 0;
220
232
  bool playStatusTemp = false;
221
- unsigned long lastPotRead = 0;
222
233
 
223
- // Select button state
224
- unsigned long selectDownAt = 0;
225
- bool selectDown = false;
226
- unsigned long selectLastDebounce = 0;
234
+ // Encoder click (SW) - short/long press state, same shape as the old SELECT button
235
+ unsigned long encClickDownAt = 0;
236
+ bool encClickDown = false;
237
+ unsigned long encClickLastDebounce = 0;
227
238
 
228
- // Up button state
229
- unsigned long upDownAt = 0;
230
- bool upDown = false;
231
- unsigned long upLastDebounce = 0;
239
+ // EJECT button - single press, no long-press timer needed
240
+ bool ejectDown = false;
241
+ unsigned long ejectLastDebounce = 0;
232
242
 
233
- // Down button state
234
- unsigned long downDownAt = 0;
235
- bool downDown = false;
236
- unsigned long downLastDebounce = 0;
243
+ // HOME/BACK button - single press; also carries the 10s Wi-Fi-reset hold
244
+ unsigned long homeDownAt = 0;
245
+ bool homeDown = false;
246
+ unsigned long homeLastDebounce = 0;
237
247
 
238
- // FF/REW speed state (used by UP/DOWN during playback)
239
- int ffSpeedIndex = 0;
240
- int rewSpeedIndex = 0;
248
+ // PLAY/PAUSE button - short = pause/resume, long = stop
249
+ unsigned long playpauseDownAt = 0;
250
+ bool playpauseDown = false;
251
+ unsigned long playpauseLastDebounce = 0;
241
252
 
242
253
  int homeIndex = 0;
243
254
  int burnModeIndex = 0;
244
255
  int burnSpeedIndex = 0;
245
256
  bool editingSpeed = false;
246
257
  int playVolume = 50;
258
+ bool playSeekMode = false; // false = encoder rotate adjusts volume (default); true = seek/track-skip
247
259
  unsigned long standbyStartTime = 0;
248
260
  unsigned long lastMsgTime = 0;
249
261
  unsigned long lastInputTime = 0; // last button press / screen change; drives the idle screensaver
@@ -251,26 +263,70 @@ bool displayBlank = false; // true = real UI hidden, disc screensaver r
251
263
  unsigned long lastSaverFrame = 0;
252
264
  int saverStep = 0;
253
265
  bool audioPlayMode = false;
266
+
267
+ // Spectrum visualizer: host streams real playback levels as "VU:v0,v1,...".
268
+ // While they keep arriving, PLAY shows full-screen bars instead of the usual
269
+ // status text; VU_TIMEOUT_MS after they stop (paused/stopped host-side) it
270
+ // falls back to the normal drawPlay() screen.
271
+ uint8_t vuLevel[VU_BARS];
272
+ bool visualizerActive = false;
273
+ unsigned long lastVuAt = 0;
274
+ unsigned long vuSuppressUntil = 0; // bars withheld (text screen shown instead) until millis() reaches this
254
275
  int displayRotation = 0;
255
276
 
256
277
  // Indeterminate progress animation
257
278
  int indeterminateCount = 0;
258
279
  unsigned long lastIndeterminateAnim = 0;
259
280
 
260
- int readBucket(int count) {
261
- int raw = analogRead(POT_PIN);
262
- int idx = raw * count / 4096;
263
- if (idx < 0) idx = 0;
264
- if (idx >= count) idx = count - 1;
265
- return idx;
266
- }
281
+ // --- Rotary encoder decode ---
282
+ // Ben Buxton's full-step quadrature state machine (the standard robust
283
+ // rotary-encoder algorithm, e.g. the basis of the Arduino "Rotary" library).
284
+ // Reads BOTH pins on every change and only commits a tick after a complete,
285
+ // valid 4-transition sequence - contact bounce just walks between
286
+ // non-committing states instead of producing a spurious tick, so no time
287
+ // debounce is needed. (An earlier CLK-only + micros()-debounce version read
288
+ // just one pin's level at one instant, which is fragile - CLK and DT edges
289
+ // aren't perfectly synchronized on a mechanical encoder, so bounce right at
290
+ // the sample instant could misread direction or double-fire: skipped,
291
+ // doubled, and wrong-direction ticks. That simpler decode was chosen to
292
+ // lighten interrupt load for the dead XIAO ESP32-C6's fragile native-USB
293
+ // link; the current board's CP2102 bridge isn't affected by that at all.)
294
+ #define ENC_R_START 0x0
295
+ #define ENC_R_CW_FINAL 0x1
296
+ #define ENC_R_CW_BEGIN 0x2
297
+ #define ENC_R_CW_NEXT 0x3
298
+ #define ENC_R_CCW_BEGIN 0x4
299
+ #define ENC_R_CCW_FINAL 0x5
300
+ #define ENC_R_CCW_NEXT 0x6
301
+ #define ENC_DIR_CW 0x10
302
+ #define ENC_DIR_CCW 0x20
303
+
304
+ const uint8_t ENC_TTABLE[7][4] = {
305
+ {ENC_R_START, ENC_R_CW_BEGIN, ENC_R_CCW_BEGIN, ENC_R_START},
306
+ {ENC_R_CW_NEXT, ENC_R_START, ENC_R_CW_FINAL, ENC_R_START | ENC_DIR_CW},
307
+ {ENC_R_CW_NEXT, ENC_R_CW_BEGIN, ENC_R_START, ENC_R_START},
308
+ {ENC_R_CW_NEXT, ENC_R_CW_BEGIN, ENC_R_CW_FINAL, ENC_R_START},
309
+ {ENC_R_CCW_NEXT, ENC_R_START, ENC_R_CCW_BEGIN, ENC_R_START},
310
+ {ENC_R_CCW_NEXT, ENC_R_CCW_FINAL, ENC_R_START, ENC_R_START | ENC_DIR_CCW},
311
+ {ENC_R_CCW_NEXT, ENC_R_CCW_FINAL, ENC_R_CCW_BEGIN, ENC_R_START},
312
+ };
267
313
 
268
- int readPercent() {
269
- int raw = analogRead(POT_PIN);
270
- int pct = raw * 100 / 4095;
271
- if (pct < 0) pct = 0;
272
- if (pct > 100) pct = 100;
273
- return ((pct + 2) / 5) * 5;
314
+ volatile uint8_t encState = ENC_R_START;
315
+ volatile int16_t encTicks = 0; // whole detents ready for loop() to drain
316
+ volatile uint32_t encLastActivityMs = 0; // refreshed on every raw pin transition, not just
317
+ // committed ticks - guards handleSelectPress's SW
318
+ // read against bounce/crosstalk from a spin in progress
319
+
320
+ void IRAM_ATTR encoderISR() {
321
+ encLastActivityMs = millis();
322
+ uint8_t pinState = (digitalRead(ENC_DT_PIN) << 1) | digitalRead(ENC_CLK_PIN);
323
+ encState = ENC_TTABLE[encState & 0xF][pinState];
324
+ uint8_t dir = encState & 0x30;
325
+ // Flipped from the table's literal CW/CCW so the tick sign matches this
326
+ // encoder's physical wiring: turning the knob clockwise should increase
327
+ // (next track, volume up), not decrease.
328
+ if (dir == ENC_DIR_CW) encTicks--;
329
+ else if (dir == ENC_DIR_CCW) encTicks++;
274
330
  }
275
331
 
276
332
  void sendHomeMode() {
@@ -585,7 +641,11 @@ bool wakeDisplay() {
585
641
  case UI_STATUS: drawStatus(); break;
586
642
  case UI_IP: drawIP(); break;
587
643
  case UI_BURN_READY: drawBurnReady(); break;
588
- case UI_PLAY: drawPlay(); break;
644
+ case UI_PLAY:
645
+ if (visualizerActive && (long)(millis() - lastVuAt) < VU_TIMEOUT_MS &&
646
+ (long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
647
+ else drawPlay();
648
+ break;
589
649
  case UI_WAITING: drawWaiting(); break;
590
650
  case UI_STANDBY: drawStandby(); break;
591
651
  case UI_DISCONNECTED: drawDisconnected(); break;
@@ -594,9 +654,35 @@ bool wakeDisplay() {
594
654
  return true;
595
655
  }
596
656
 
657
+ #define VU_TOP_MARGIN 16 // px of empty headroom above the tallest possible bar
658
+
659
+ // Full-screen-width spectrum bars, no header/chrome, capped short of the top
660
+ // edge (VU_TOP_MARGIN) so a loud peak doesn't run the panel edge-to-edge.
661
+ // Falls back to the normal drawPlay() text screen once VU: updates stop
662
+ // arriving (see VU_TIMEOUT_MS in loop()).
663
+ void drawPlayVisualizer() {
664
+ uiState = UI_PLAY;
665
+ returnToHomeAt = 0;
666
+ if (!displayOk) return;
667
+
668
+ display.clearDisplay();
669
+ const int gap = 2;
670
+ const int maxH = SCREEN_HEIGHT - VU_TOP_MARGIN;
671
+ const int barW = (SCREEN_WIDTH - gap * (VU_BARS - 1)) / VU_BARS;
672
+ int x = (SCREEN_WIDTH - (barW * VU_BARS + gap * (VU_BARS - 1))) / 2;
673
+ for (int i = 0; i < VU_BARS; i++) {
674
+ int h = map(vuLevel[i], 0, 63, 0, maxH);
675
+ if (h > 0) display.fillRect(x, SCREEN_HEIGHT - h, barW, h, SSD1306_WHITE);
676
+ x += barW + gap;
677
+ }
678
+ display.display();
679
+ }
680
+
597
681
  void drawPlay() {
598
682
  uiState = UI_PLAY;
599
683
  returnToHomeAt = 0;
684
+ lastInputTime = millis(); // real status content (track change, pause/resume) gets a full
685
+ // IDLE_BLANK_MS on screen before the screensaver reclaims it
600
686
  if (!displayOk) return;
601
687
 
602
688
  display.clearDisplay();
@@ -607,13 +693,16 @@ void drawPlay() {
607
693
  display.setCursor(2, 30);
608
694
  printUpper(line2);
609
695
  display.setCursor(2, 44);
610
- display.print("VOL // ");
611
- display.print(playVolume);
612
- display.print("%");
613
- if (!audioPlayMode) {
614
- display.setCursor(2, 56);
615
- display.print("SELECT // PLAY");
696
+ if (playSeekMode) {
697
+ display.print(audioPlayMode ? "TURN // SKIP TRACK" : "TURN // SEEK");
698
+ } else {
699
+ display.print("VOL // ");
700
+ display.print(playVolume);
701
+ display.print("%");
616
702
  }
703
+ display.setCursor(2, 56);
704
+ display.print("CLICK // ");
705
+ display.print(playSeekMode ? "VOL MODE" : "SEEK MODE");
617
706
 
618
707
  display.display();
619
708
  }
@@ -628,6 +717,28 @@ void parseMessage(String msg) {
628
717
  return;
629
718
  }
630
719
 
720
+ if (msg.startsWith("VU:")) {
721
+ // No wakeDisplay()/RCV echo here - this arrives ~15x/sec while playing,
722
+ // wakeDisplay() would draw stale bars a frame early, and echoing it
723
+ // would spam the serial log for no reason.
724
+ lastMsgTime = millis();
725
+ String rest = msg.substring(3);
726
+ for (int i = 0; i < VU_BARS; i++) vuLevel[i] = 0;
727
+ for (int i = 0; i < VU_BARS && rest.length() > 0; i++) {
728
+ int comma = rest.indexOf(',');
729
+ String tok = (comma < 0) ? rest : rest.substring(0, comma);
730
+ vuLevel[i] = (uint8_t)constrain(tok.toInt(), 0, 63);
731
+ if (comma < 0) break;
732
+ rest = rest.substring(comma + 1);
733
+ }
734
+ visualizerActive = true;
735
+ lastVuAt = millis();
736
+ lastInputTime = millis(); // the visualizer's own continuous redraw already beats the
737
+ // power-bank shutoff - no need for the screensaver too
738
+ if (uiState == UI_PLAY && (long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
739
+ return;
740
+ }
741
+
631
742
  wakeDisplay();
632
743
  lastMsgTime = millis();
633
744
 
@@ -635,7 +746,6 @@ void parseMessage(String msg) {
635
746
  Out.println(msg);
636
747
 
637
748
  if (msg.startsWith("HOME:")) {
638
- homeIndex = readBucket(homeCount);
639
749
  drawHome();
640
750
 
641
751
  } else if (msg.startsWith("DISC:")) {
@@ -669,7 +779,6 @@ void parseMessage(String msg) {
669
779
  } else if (msg.startsWith("TITLE:")) {
670
780
  titleLine = msg.substring(6);
671
781
  if (titleLine.length() > 20) titleLine = titleLine.substring(0, 20);
672
- burnModeIndex = readBucket(BURN_COUNT);
673
782
  drawBurnReady();
674
783
 
675
784
  } else if (msg.startsWith("META:")) {
@@ -685,8 +794,9 @@ void parseMessage(String msg) {
685
794
  } else if (msg.startsWith("PLAY:")) {
686
795
  line1 = msg.substring(5);
687
796
  line2 = "Playing disc";
688
- playVolume = readPercent();
689
- Out.print("POT:"); // push the knob's current level so playback starts at it
797
+ playSeekMode = false; // always start in volume mode
798
+ vuSuppressUntil = millis() + VU_ENTRY_DELAY_MS; // text screen first, bars after a beat
799
+ Out.print("POT:"); // push the last-used volume so playback starts at it
690
800
  Out.println(playVolume);
691
801
  drawPlay();
692
802
 
@@ -796,16 +906,16 @@ void setup() {
796
906
  Serial.begin(115200);
797
907
  esp_task_wdt_add(NULL);
798
908
  Wire.begin(21, 22);
799
- pinMode(BTN_SELECT_PIN, INPUT_PULLUP);
800
- pinMode(BTN_UP_PIN, INPUT_PULLUP);
801
- pinMode(BTN_DOWN_PIN, INPUT_PULLUP);
802
- pinMode(POT_PIN, INPUT);
803
- analogReadResolution(12);
804
- analogSetPinAttenuation(POT_PIN, ADC_11db);
805
-
806
- homeIndex = readBucket(homeCount);
807
- burnModeIndex = readBucket(BURN_COUNT);
808
- playVolume = readPercent();
909
+ Wire.setClock(400000); // SSD1306 supports I2C fast-mode; the default 100kHz was too slow to
910
+ // push a full 128x64 frame at the visualizer's ~15fps without stutter
911
+ pinMode(BTN_EJECT_PIN, INPUT_PULLUP);
912
+ pinMode(BTN_HOME_PIN, INPUT_PULLUP);
913
+ pinMode(BTN_PLAYPAUSE_PIN, INPUT_PULLUP);
914
+ pinMode(ENC_SW_PIN, INPUT_PULLUP);
915
+ pinMode(ENC_CLK_PIN, INPUT_PULLUP);
916
+ pinMode(ENC_DT_PIN, INPUT_PULLUP);
917
+ attachInterrupt(digitalPinToInterrupt(ENC_CLK_PIN), encoderISR, CHANGE);
918
+ attachInterrupt(digitalPinToInterrupt(ENC_DT_PIN), encoderISR, CHANGE);
809
919
 
810
920
  displayOk = display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS);
811
921
 
@@ -898,100 +1008,163 @@ void handleSelectPress(bool longPress) {
898
1008
  Out.println("CANCEL");
899
1009
  drawStandby();
900
1010
 
901
- } else if (uiState == UI_STANDBY) {
902
- displayRotation = 2;
903
- drawStandby();
904
1011
  } else if (uiState == UI_PLAY) {
905
- ffSpeedIndex = 0;
906
- rewSpeedIndex = 0;
1012
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
907
1013
  if (longPress) {
908
1014
  Out.println("PLAY_STOP");
909
1015
  } else {
910
- Out.println("PLAY_BUTTON");
1016
+ playSeekMode = !playSeekMode; // encoder click swaps what rotating does
1017
+ drawPlay();
911
1018
  }
912
1019
  }
913
1020
  }
914
1021
 
915
- void handleUp(bool longPress) {
916
- if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only press, swallow the action
1022
+ // Encoder rotation replaces UP/DOWN. One tick = one detent (see encoderISR).
1023
+ // STANDBY's displayRotation flip and BURN_READY's mode/speed scroll are
1024
+ // otherwise unchanged from the old UP/DOWN behavior. PLAY defaults to
1025
+ // adjusting volume; a short encoder click (see handleSelectPress) swaps it
1026
+ // to seek/track-skip instead - there's only one rotate axis now, where
1027
+ // before the pot (volume) and UP/DOWN (seek) were independent.
1028
+ void handleEncoderCW() {
1029
+ if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only turn, swallow the action
917
1030
  lastInputTime = millis();
918
1031
  if (uiState == UI_HOME) {
919
1032
  if (homeCount > 0) {
920
- homeIndex = (homeIndex - 1 + homeCount) % homeCount;
1033
+ homeIndex = (homeIndex + 1) % homeCount;
921
1034
  sendHomeMode();
922
1035
  drawHome();
923
1036
  }
924
1037
  } else if (uiState == UI_BURN_READY) {
925
1038
  if (editingSpeed) {
926
- burnSpeedIndex = (burnSpeedIndex - 1 + SPEED_COUNT) % SPEED_COUNT;
1039
+ burnSpeedIndex = (burnSpeedIndex + 1) % SPEED_COUNT;
927
1040
  Out.print("SPEED:");
928
1041
  Out.println(SPEED_MODES[burnSpeedIndex]);
929
1042
  drawBurnReady();
930
1043
  } else {
931
- burnModeIndex = (burnModeIndex - 1 + BURN_COUNT) % BURN_COUNT;
1044
+ burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
932
1045
  sendBurnMode();
933
1046
  drawBurnReady();
934
1047
  }
935
1048
  } else if (uiState == UI_STANDBY) {
936
- displayRotation = 0;
1049
+ displayRotation = 2;
937
1050
  drawStandby();
938
1051
  } else if (uiState == UI_PLAY) {
939
- if (audioPlayMode) {
940
- Out.println(displayRotation == 0 ? (longPress ? "REW:BIG" : "REW:10") : (longPress ? "FF:BIG" : "FF:10"));
941
- return;
942
- }
943
- rewSpeedIndex = 0;
944
- if (longPress) {
945
- Out.println("FF:BIG");
1052
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
1053
+ if (!playSeekMode) {
1054
+ playVolume = min(100, playVolume + 5);
1055
+ Out.print("POT:");
1056
+ Out.println(playVolume);
1057
+ drawPlay();
1058
+ } else if (audioPlayMode) {
1059
+ Out.println(displayRotation == 0 ? "FF:BIG" : "REW:BIG"); // next/prev track, screen-flip-aware
946
1060
  } else {
947
- ffSpeedIndex = (ffSpeedIndex + 1) % 4;
948
- int seek_sec[] = {10, 20, 30, 60};
949
- Out.print("FF:");
950
- Out.println(seek_sec[ffSpeedIndex]);
1061
+ Out.println("FF:10");
951
1062
  }
952
1063
  }
953
1064
  }
954
1065
 
955
- void handleDown(bool longPress) {
956
- if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only press, swallow the action
1066
+ void handleEncoderCCW() {
1067
+ if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only turn, swallow the action
957
1068
  lastInputTime = millis();
958
1069
  if (uiState == UI_HOME) {
959
1070
  if (homeCount > 0) {
960
- homeIndex = (homeIndex + 1) % homeCount;
1071
+ homeIndex = (homeIndex - 1 + homeCount) % homeCount;
961
1072
  sendHomeMode();
962
1073
  drawHome();
963
1074
  }
964
1075
  } else if (uiState == UI_BURN_READY) {
965
1076
  if (editingSpeed) {
966
- burnSpeedIndex = (burnSpeedIndex + 1) % SPEED_COUNT;
1077
+ burnSpeedIndex = (burnSpeedIndex - 1 + SPEED_COUNT) % SPEED_COUNT;
967
1078
  Out.print("SPEED:");
968
1079
  Out.println(SPEED_MODES[burnSpeedIndex]);
969
1080
  drawBurnReady();
970
1081
  } else {
971
- burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
1082
+ burnModeIndex = (burnModeIndex - 1 + BURN_COUNT) % BURN_COUNT;
972
1083
  sendBurnMode();
973
1084
  drawBurnReady();
974
1085
  }
975
1086
  } else if (uiState == UI_STANDBY) {
976
- displayRotation = 2;
1087
+ displayRotation = 0;
977
1088
  drawStandby();
978
1089
  } else if (uiState == UI_PLAY) {
979
- if (audioPlayMode) {
980
- Out.println(displayRotation == 0 ? (longPress ? "FF:BIG" : "FF:10") : (longPress ? "REW:BIG" : "REW:10"));
981
- return;
982
- }
983
- ffSpeedIndex = 0;
984
- if (longPress) {
985
- Out.println("REW:BIG");
1090
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
1091
+ if (!playSeekMode) {
1092
+ playVolume = max(0, playVolume - 5);
1093
+ Out.print("POT:");
1094
+ Out.println(playVolume);
1095
+ drawPlay();
1096
+ } else if (audioPlayMode) {
1097
+ Out.println(displayRotation == 0 ? "REW:BIG" : "FF:BIG");
986
1098
  } else {
987
- rewSpeedIndex = (rewSpeedIndex + 1) % 4;
988
- int seek_sec[] = {10, 20, 30, 60};
989
- Out.print("REW:");
990
- Out.println(seek_sec[rewSpeedIndex]);
1099
+ Out.println("REW:10");
991
1100
  }
992
1101
  }
993
1102
  }
994
1103
 
1104
+ // EJECT: fixed shortcut, any idle-ish state. Not during an active burn/rip -
1105
+ // don't pull the disc mid-operation (matches the old SELECT-EJECT's implicit
1106
+ // restriction, since that branch only ever existed on HOME/STANDBY).
1107
+ void handleEjectButton() {
1108
+ if (wakeDisplay()) { lastInputTime = millis(); return; }
1109
+ lastInputTime = millis();
1110
+ if (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_DISCONNECTED || uiState == UI_PLAY) {
1111
+ Out.println("EJECT");
1112
+ line1 = "Ejecting...";
1113
+ line2 = "";
1114
+ line3 = "";
1115
+ returnToHomeAt = millis() + 5000;
1116
+ drawStatus();
1117
+ }
1118
+ }
1119
+
1120
+ // HOME/BACK: universal escape from anywhere back to the top menu.
1121
+ void handleHomeButton() {
1122
+ if (wakeDisplay()) { lastInputTime = millis(); return; }
1123
+ lastInputTime = millis();
1124
+ if (uiState == UI_HOME) return;
1125
+ Out.println(uiState == UI_PLAY ? "PLAY_STOP" : "CANCEL");
1126
+ drawHome();
1127
+ }
1128
+
1129
+ // PLAY/PAUSE: toggles pause while playing; from HOME, jumps straight into
1130
+ // PLAY without needing to scroll the menu there first.
1131
+ void handlePlayPauseButton(bool longPress) {
1132
+ if (wakeDisplay()) { lastInputTime = millis(); return; }
1133
+ lastInputTime = millis();
1134
+ if (uiState == UI_PLAY) {
1135
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
1136
+ Out.println(longPress ? "PLAY_STOP" : "PLAY_BUTTON");
1137
+ } else if (uiState == UI_HOME && !longPress) {
1138
+ bool hasPlay = false;
1139
+ for (int i = 0; i < homeCount; i++) if (homeModes[i] == "PLAY") hasPlay = true;
1140
+ if (hasPlay) {
1141
+ Out.println("SELECT:PLAY");
1142
+ line1 = "Selected";
1143
+ line2 = "PLAY";
1144
+ line3 = "";
1145
+ drawStatus();
1146
+ }
1147
+ }
1148
+ }
1149
+
1150
+ // Drains every line currently buffered on a link in one go instead of one
1151
+ // per loop() call. VU: frames arrive fast enough (~15/sec) that drawing each
1152
+ // one in turn made the display fall further and further behind real-time
1153
+ // once a single draw took longer than the send interval - only the last VU:
1154
+ // seen in a batch is kept/rendered, so the visualizer always shows "now"
1155
+ // instead of working through a backlog. Every other message type still gets
1156
+ // parsed in order; only VU: is collapsible like this.
1157
+ void drainAndDispatch(Stream &s) {
1158
+ String pendingVu = "";
1159
+ while (s.available()) {
1160
+ String msg = s.readStringUntil('\n');
1161
+ if (msg.length() == 0) continue;
1162
+ if (msg.startsWith("VU:")) pendingVu = msg;
1163
+ else parseMessage(msg);
1164
+ }
1165
+ if (pendingVu.length() > 0) parseMessage(pendingVu);
1166
+ }
1167
+
995
1168
  void loop() {
996
1169
  if (!wifiInitDone && millis() > 3000) {
997
1170
  wifiInitDone = true;
@@ -1034,15 +1207,13 @@ void loop() {
1034
1207
  Out.setClient(nullptr);
1035
1208
  }
1036
1209
  if (tcpClient && tcpClient.available()) {
1037
- String msg = tcpClient.readStringUntil('\n');
1038
- if (msg.length() > 0) parseMessage(msg);
1210
+ drainAndDispatch(tcpClient);
1039
1211
  }
1040
1212
  }
1041
1213
 
1042
1214
  esp_task_wdt_reset();
1043
1215
  if (Serial.available()) {
1044
- String msg = Serial.readStringUntil('\n');
1045
- parseMessage(msg);
1216
+ drainAndDispatch(Serial);
1046
1217
  }
1047
1218
 
1048
1219
  if (returnToHomeAt != 0 && (long)(millis() - returnToHomeAt) >= 0) {
@@ -1055,31 +1226,59 @@ void loop() {
1055
1226
  drawPlay();
1056
1227
  }
1057
1228
 
1058
- // --- Pot reading (PLAY only, volume) ---
1059
- if (uiState == UI_PLAY && millis() - lastPotRead > POT_READ_MS) {
1060
- lastPotRead = millis();
1061
- int nextVolume = readPercent();
1062
- if (abs(nextVolume - playVolume) >= 3) {
1063
- playVolume = nextVolume;
1064
- Out.print("POT:");
1065
- Out.println(playVolume);
1066
- drawPlay();
1229
+ // --- Encoder rotation: drain ticks accumulated by encoderISR ---
1230
+ {
1231
+ noInterrupts();
1232
+ int16_t ticks = encTicks;
1233
+ encTicks = 0;
1234
+ interrupts();
1235
+ while (ticks > 0) { handleEncoderCW(); ticks--; }
1236
+ while (ticks < 0) { handleEncoderCCW(); ticks++; }
1237
+ }
1238
+
1239
+ // --- Encoder click (SW) - same debounce/long-press shape as the old SELECT.
1240
+ // Also guarded against rotation: spinning the knob can momentarily bounce
1241
+ // SW low too, which was being misread as a real click and firing SELECT/PLAY. ---
1242
+ {
1243
+ bool sw = digitalRead(ENC_SW_PIN) == LOW;
1244
+ if (sw && !encClickDown && millis() - encClickLastDebounce > DEBOUNCE_MS &&
1245
+ millis() - encLastActivityMs > ENC_CLICK_GUARD_MS) {
1246
+ encClickDown = true;
1247
+ encClickDownAt = millis();
1248
+ }
1249
+ if (!sw && encClickDown) {
1250
+ encClickDown = false;
1251
+ encClickLastDebounce = millis();
1252
+ handleSelectPress(millis() - encClickDownAt >= LONG_PRESS_MS);
1067
1253
  }
1068
1254
  }
1069
1255
 
1070
- // --- SELECT button ---
1256
+ // --- EJECT button (single press, no long-press timer) ---
1071
1257
  {
1072
- bool sel = digitalRead(BTN_SELECT_PIN) == LOW;
1073
- if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
1074
- selectDown = true;
1075
- selectDownAt = millis();
1258
+ bool ej = digitalRead(BTN_EJECT_PIN) == LOW;
1259
+ if (ej && !ejectDown && millis() - ejectLastDebounce > DEBOUNCE_MS) {
1260
+ ejectDown = true;
1261
+ }
1262
+ if (!ej && ejectDown) {
1263
+ ejectDown = false;
1264
+ ejectLastDebounce = millis();
1265
+ handleEjectButton();
1266
+ }
1267
+ }
1268
+
1269
+ // --- HOME/BACK button (single press; 10s hold = Wi-Fi reset) ---
1270
+ {
1271
+ bool hm = digitalRead(BTN_HOME_PIN) == LOW;
1272
+ if (hm && !homeDown && millis() - homeLastDebounce > DEBOUNCE_MS) {
1273
+ homeDown = true;
1274
+ homeDownAt = millis();
1076
1275
  wifiResetArmed = false;
1077
1276
  }
1078
- // 10s hold on HOME (or SETUP) = wipe Wi-Fi creds + reboot to portal. Fires
1079
- // while still held so the eventual release doesn't also trigger EJECT.
1080
- if (sel && selectDown && !wifiResetArmed &&
1277
+ // 10s hold on HOME/SETUP/STANDBY = wipe Wi-Fi creds + reboot to portal. Fires
1278
+ // while still held so the eventual release doesn't also trigger the normal action.
1279
+ if (hm && homeDown && !wifiResetArmed &&
1081
1280
  (uiState == UI_HOME || uiState == UI_SETUP || uiState == UI_STANDBY) &&
1082
- millis() - selectDownAt >= WIFI_RESET_HOLD_MS) {
1281
+ millis() - homeDownAt >= WIFI_RESET_HOLD_MS) {
1083
1282
  wifiResetArmed = true;
1084
1283
  Out.println("WiFi: creds wiped, rebooting");
1085
1284
  clearCreds();
@@ -1087,47 +1286,25 @@ void loop() {
1087
1286
  delay(800);
1088
1287
  ESP.restart();
1089
1288
  }
1090
- if (!sel && selectDown) {
1091
- selectDown = false;
1092
- selectLastDebounce = millis();
1093
- if (!wifiResetArmed) {
1094
- handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
1095
- }
1289
+ if (!hm && homeDown) {
1290
+ homeDown = false;
1291
+ homeLastDebounce = millis();
1292
+ if (!wifiResetArmed) handleHomeButton();
1096
1293
  wifiResetArmed = false;
1097
1294
  }
1098
1295
  }
1099
1296
 
1100
- // --- UP button ---
1101
- {
1102
- bool up = digitalRead(BTN_UP_PIN) == LOW;
1103
- if (up && !upDown && millis() - upLastDebounce > DEBOUNCE_MS) {
1104
- upDown = true;
1105
- upDownAt = millis();
1106
- }
1107
- if (!up && upDown) {
1108
- upDown = false;
1109
- upLastDebounce = millis();
1110
- bool lp = millis() - upDownAt >= LONG_PRESS_MS;
1111
- if (uiState == UI_PLAY || !lp) {
1112
- handleUp(lp);
1113
- }
1114
- }
1115
- }
1116
-
1117
- // --- DOWN button ---
1297
+ // --- PLAY/PAUSE button (short = pause/resume, long = stop) ---
1118
1298
  {
1119
- bool dn = digitalRead(BTN_DOWN_PIN) == LOW;
1120
- if (dn && !downDown && millis() - downLastDebounce > DEBOUNCE_MS) {
1121
- downDown = true;
1122
- downDownAt = millis();
1299
+ bool pp = digitalRead(BTN_PLAYPAUSE_PIN) == LOW;
1300
+ if (pp && !playpauseDown && millis() - playpauseLastDebounce > DEBOUNCE_MS) {
1301
+ playpauseDown = true;
1302
+ playpauseDownAt = millis();
1123
1303
  }
1124
- if (!dn && downDown) {
1125
- downDown = false;
1126
- downLastDebounce = millis();
1127
- bool lp = millis() - downDownAt >= LONG_PRESS_MS;
1128
- if (uiState == UI_PLAY || !lp) {
1129
- handleDown(lp);
1130
- }
1304
+ if (!pp && playpauseDown) {
1305
+ playpauseDown = false;
1306
+ playpauseLastDebounce = millis();
1307
+ handlePlayPauseButton(millis() - playpauseDownAt >= LONG_PRESS_MS);
1131
1308
  }
1132
1309
  }
1133
1310
 
@@ -1139,9 +1316,18 @@ void loop() {
1139
1316
  if (!displayBlank) drawStatus();
1140
1317
  }
1141
1318
 
1142
- // --- Idle disc screensaver (HOME + STANDBY) ---
1319
+ // --- Visualizer timeout: host stopped sending VU: (paused/stopped) ---
1320
+ if (visualizerActive && (long)(millis() - lastVuAt) >= VU_TIMEOUT_MS) {
1321
+ visualizerActive = false;
1322
+ if (uiState == UI_PLAY && !displayBlank) drawPlay();
1323
+ }
1324
+
1325
+ // --- Idle disc screensaver (HOME + STANDBY + PLAY) ---
1326
+ // PLAY is included because a static "PLAYING" screen is just as low-current
1327
+ // as HOME/STANDBY were - the power bank doesn't care what's on screen, only
1328
+ // that the draw stays static this long.
1143
1329
  if (displayOk && !displayBlank &&
1144
- (uiState == UI_HOME || uiState == UI_STANDBY) &&
1330
+ (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
1145
1331
  (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
1146
1332
  displayBlank = true;
1147
1333
  saverStep = 0;