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.
- package/README.md +5 -8
- package/arduino/{v1 → DiscStation}/DiscStation.ino +341 -155
- package/docs/PLATFORM_SUPPORT.md +16 -0
- package/install-macos.sh +6 -3
- package/install.sh +1 -1
- package/package.json +1 -1
- package/requirements-optional.txt +8 -0
- package/src/discstation.py +156 -1
- package/src/discstation_burn.py +15 -2
- package/arduino/c6/DiscStation_C6.ino +0 -1185
- package/arduino/c6/secrets.h.example +0 -14
- /package/arduino/{v1 → DiscStation}/secrets.h.example +0 -0
|
@@ -22,20 +22,31 @@
|
|
|
22
22
|
#define OLED_RESET -1
|
|
23
23
|
#define I2C_ADDRESS 0x3C
|
|
24
24
|
|
|
25
|
-
#define
|
|
26
|
-
#define
|
|
27
|
-
#define
|
|
28
|
-
#define
|
|
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
|
|
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
|
|
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
|
-
//
|
|
224
|
-
unsigned long
|
|
225
|
-
bool
|
|
226
|
-
unsigned long
|
|
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
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
//
|
|
234
|
-
unsigned long
|
|
235
|
-
bool
|
|
236
|
-
unsigned long
|
|
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
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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:
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
display.
|
|
615
|
-
display.print("
|
|
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
|
-
|
|
689
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
pinMode(
|
|
802
|
-
pinMode(
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1016
|
+
playSeekMode = !playSeekMode; // encoder click swaps what rotating does
|
|
1017
|
+
drawPlay();
|
|
911
1018
|
}
|
|
912
1019
|
}
|
|
913
1020
|
}
|
|
914
1021
|
|
|
915
|
-
|
|
916
|
-
|
|
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
|
|
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
|
|
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
|
|
1044
|
+
burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
|
|
932
1045
|
sendBurnMode();
|
|
933
1046
|
drawBurnReady();
|
|
934
1047
|
}
|
|
935
1048
|
} else if (uiState == UI_STANDBY) {
|
|
936
|
-
displayRotation =
|
|
1049
|
+
displayRotation = 2;
|
|
937
1050
|
drawStandby();
|
|
938
1051
|
} else if (uiState == UI_PLAY) {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
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
|
|
956
|
-
if (wakeDisplay()) { lastInputTime = millis(); return; } // wake-only
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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 =
|
|
1087
|
+
displayRotation = 0;
|
|
977
1088
|
drawStandby();
|
|
978
1089
|
} else if (uiState == UI_PLAY) {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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
|
-
// ---
|
|
1256
|
+
// --- EJECT button (single press, no long-press timer) ---
|
|
1071
1257
|
{
|
|
1072
|
-
bool
|
|
1073
|
-
if (
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
|
1079
|
-
// while still held so the eventual release doesn't also trigger
|
|
1080
|
-
if (
|
|
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() -
|
|
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 (!
|
|
1091
|
-
|
|
1092
|
-
|
|
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
|
-
// ---
|
|
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
|
|
1120
|
-
if (
|
|
1121
|
-
|
|
1122
|
-
|
|
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 (!
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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
|
-
// ---
|
|
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;
|