discstation 0.1.0

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.
@@ -0,0 +1,957 @@
1
+ #include <Wire.h>
2
+ #include <Adafruit_GFX.h>
3
+ #include <Adafruit_SSD1306.h>
4
+ #include "esp_task_wdt.h"
5
+ #include <WiFi.h>
6
+ #include <ArduinoOTA.h>
7
+ #include <QRCode.h>
8
+
9
+ #define SCREEN_WIDTH 128
10
+ #define SCREEN_HEIGHT 64
11
+ #define OLED_RESET -1
12
+ #define I2C_ADDRESS 0x3C
13
+
14
+ #define BTN_SELECT_PIN 14
15
+ #define BTN_UP_PIN 13
16
+ #define BTN_DOWN_PIN 15
17
+ #define LED_POWER_PIN 32
18
+ #define LED_ACT_PIN 33
19
+ #define POT_PIN 34
20
+
21
+ #define DEBOUNCE_MS 50
22
+ #define LONG_PRESS_MS 1000
23
+ #define POT_READ_MS 200
24
+ #define DONE_RESET_MS 30000
25
+ #define STANDBY_BLANK_MS 60000
26
+ #define PING_TIMEOUT_MS 30000
27
+ #define LED_BLINK_MS 250
28
+
29
+ #define MAX_HOME_MODES 5
30
+ #define BURN_COUNT 4
31
+ #define SPEED_COUNT 4
32
+ #define TCP_PORT 2323
33
+
34
+ int homeCount = 3;
35
+ String homeModes[MAX_HOME_MODES] = {"BURN", "PLAY", "RIP"};
36
+
37
+ WiFiServer tcpServer(TCP_PORT);
38
+ WiFiClient tcpClient;
39
+ bool wifiConnected = false;
40
+ bool wifiInitDone = false;
41
+
42
+ class DualPrint : public Print {
43
+ public:
44
+ void setClient(WiFiClient* c) { _client = c; }
45
+ size_t write(uint8_t c) override {
46
+ Serial.write(c);
47
+ if (_client && _client->connected()) _client->write(c);
48
+ return 1;
49
+ }
50
+ size_t write(const uint8_t* buf, size_t len) override {
51
+ Serial.write(buf, len);
52
+ if (_client && _client->connected()) _client->write(buf, len);
53
+ return len;
54
+ }
55
+ private:
56
+ WiFiClient* _client = nullptr;
57
+ } Out;
58
+
59
+ void initWiFi() {
60
+ Out.print("WiFi Tez...");
61
+ WiFi.mode(WIFI_STA);
62
+ WiFi.begin("Tez", "Dellwin8#$");
63
+ for (int i = 0; i < 80; i++) {
64
+ if (WiFi.status() == WL_CONNECTED) break;
65
+ delay(250);
66
+ }
67
+ if (WiFi.status() == WL_CONNECTED) {
68
+ wifiConnected = true;
69
+ tcpServer.begin();
70
+ ArduinoOTA.begin();
71
+ ArduinoOTA.setHostname("discstation-v1");
72
+ ArduinoOTA.setPassword("dvdstation");
73
+ Out.print(" OK "); Out.println(WiFi.localIP());
74
+ } else {
75
+ Out.println(" fail");
76
+ }
77
+ }
78
+ String discName = "";
79
+
80
+ Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
81
+
82
+ String ipUrl = "";
83
+
84
+ enum UiState {
85
+ UI_STATUS,
86
+ UI_HOME,
87
+ UI_BURN_READY,
88
+ UI_PLAY,
89
+ UI_STANDBY,
90
+ UI_WAITING,
91
+ UI_IP,
92
+ UI_DISCONNECTED
93
+ };
94
+
95
+ const char* BURN_MODES[BURN_COUNT] = {"AUTO", "BEST", "LONG", "TEST"};
96
+ const char* SPEED_MODES[SPEED_COUNT] = {"Auto", "6x", "4x", "3x"};
97
+
98
+ UiState uiState = UI_STATUS;
99
+
100
+ String titleLine = "";
101
+ String metaLine = "";
102
+ String fitLine = "";
103
+ String discLine = "Disc: none";
104
+ String line1 = "Status: READY";
105
+ String line2 = "";
106
+ String line3 = "";
107
+ String waitingLine1 = "";
108
+ String waitingLine2 = "";
109
+ int progressPercent = -1;
110
+
111
+ bool displayOk = false;
112
+ unsigned long returnToHomeAt = 0;
113
+ unsigned long playStatusAt = 0;
114
+ bool playStatusTemp = false;
115
+ unsigned long lastPotRead = 0;
116
+
117
+ // Select button state
118
+ unsigned long selectDownAt = 0;
119
+ bool selectDown = false;
120
+ unsigned long selectLastDebounce = 0;
121
+
122
+ // Up button state
123
+ unsigned long upDownAt = 0;
124
+ bool upDown = false;
125
+ unsigned long upLastDebounce = 0;
126
+
127
+ // Down button state
128
+ unsigned long downDownAt = 0;
129
+ bool downDown = false;
130
+ unsigned long downLastDebounce = 0;
131
+
132
+ // FF/REW speed state (used by UP/DOWN during playback)
133
+ int ffSpeedIndex = 0;
134
+ int rewSpeedIndex = 0;
135
+
136
+ // LED state
137
+ bool actLedState = false;
138
+ unsigned long lastLedBlink = 0;
139
+ int homeIndex = 0;
140
+ int burnModeIndex = 0;
141
+ int burnSpeedIndex = 0;
142
+ bool editingSpeed = false;
143
+ int playVolume = 50;
144
+ unsigned long standbyStartTime = 0;
145
+ unsigned long lastMsgTime = 0;
146
+ bool displayBlank = false;
147
+ bool audioPlayMode = false;
148
+ int displayRotation = 0;
149
+
150
+ // Indeterminate progress animation
151
+ int indeterminateCount = 0;
152
+ unsigned long lastIndeterminateAnim = 0;
153
+
154
+ int readBucket(int count) {
155
+ int raw = analogRead(POT_PIN);
156
+ int idx = raw * count / 4096;
157
+ if (idx < 0) idx = 0;
158
+ if (idx >= count) idx = count - 1;
159
+ return idx;
160
+ }
161
+
162
+ int readPercent() {
163
+ int raw = analogRead(POT_PIN);
164
+ int pct = raw * 100 / 4095;
165
+ if (pct < 0) pct = 0;
166
+ if (pct > 100) pct = 100;
167
+ return ((pct + 2) / 5) * 5;
168
+ }
169
+
170
+ void sendHomeMode() {
171
+ if (homeIndex >= 0 && homeIndex < homeCount) {
172
+ Out.print("MENU:");
173
+ Out.println(homeModes[homeIndex]);
174
+ }
175
+ }
176
+
177
+ void sendBurnMode() {
178
+ Out.print("MODE:");
179
+ Out.println(BURN_MODES[burnModeIndex]);
180
+ }
181
+
182
+ void printUpper(String value) {
183
+ value.toUpperCase();
184
+ display.print(value);
185
+ }
186
+
187
+ void drawChrome() {
188
+ display.setRotation(displayRotation);
189
+ display.setTextSize(1);
190
+ display.setTextColor(SSD1306_WHITE);
191
+ display.drawLine(0, 0, 4, 0, SSD1306_WHITE);
192
+ display.drawLine(0, 0, 0, 4, SSD1306_WHITE);
193
+ display.drawLine(123, 0, 127, 0, SSD1306_WHITE);
194
+ display.drawLine(127, 0, 127, 4, SSD1306_WHITE);
195
+ display.drawLine(0, 63, 4, 63, SSD1306_WHITE);
196
+ display.drawLine(0, 59, 0, 63, SSD1306_WHITE);
197
+ display.drawLine(123, 63, 127, 63, SSD1306_WHITE);
198
+ display.drawLine(127, 59, 127, 63, SSD1306_WHITE);
199
+ }
200
+
201
+ void drawHeader() {
202
+ display.setTextSize(1);
203
+ display.setTextColor(SSD1306_WHITE);
204
+ drawChrome();
205
+ display.setCursor(4, 0);
206
+ display.print("DISCSTATION // PANEL");
207
+ display.drawLine(6, 10, 121, 10, SSD1306_WHITE);
208
+ }
209
+
210
+ void drawHome() {
211
+ uiState = UI_HOME;
212
+ returnToHomeAt = 0;
213
+ if (!displayOk) return;
214
+
215
+ display.clearDisplay();
216
+ drawHeader();
217
+
218
+ int y = 14;
219
+ if (discName.length() > 0) {
220
+ display.setTextSize(1);
221
+ display.setCursor(2, y);
222
+ printUpper(discName);
223
+ y += 10;
224
+ } else {
225
+ display.setCursor(2, y);
226
+ printUpper(discLine);
227
+ y += 10;
228
+ }
229
+
230
+ for (int i = 0; i < homeCount; i++) {
231
+ display.setCursor(2, y);
232
+ display.print(i == homeIndex ? ">" : " ");
233
+ display.setCursor(14, y);
234
+ printUpper(homeModes[i]);
235
+ y += 10;
236
+ }
237
+
238
+ display.display();
239
+ }
240
+
241
+ void drawBurnReady() {
242
+ uiState = UI_BURN_READY;
243
+ returnToHomeAt = 0;
244
+ if (!displayOk) return;
245
+
246
+ display.clearDisplay();
247
+ drawHeader();
248
+
249
+ display.setCursor(2, 14);
250
+ printUpper(titleLine);
251
+
252
+ display.setCursor(2, 25);
253
+ printUpper(metaLine);
254
+
255
+ display.setCursor(2, 37);
256
+ printUpper(fitLine);
257
+
258
+ display.setCursor(2, 49);
259
+ display.print(editingSpeed ? ' ' : '>');
260
+ display.setCursor(14, 49);
261
+ display.print(BURN_MODES[burnModeIndex]);
262
+
263
+ display.setCursor(62, 49);
264
+ display.print(editingSpeed ? '>' : ' ');
265
+ display.setCursor(74, 49);
266
+ display.print(SPEED_MODES[burnSpeedIndex]);
267
+
268
+ display.setCursor(2, 57);
269
+ display.print("HOLD SELECT // GO");
270
+
271
+ display.display();
272
+ }
273
+
274
+ void drawWaiting() {
275
+ uiState = UI_WAITING;
276
+ returnToHomeAt = 0;
277
+ if (!displayOk) return;
278
+
279
+ display.clearDisplay();
280
+ drawHeader();
281
+
282
+ display.setCursor(2, 16);
283
+ printUpper(waitingLine1);
284
+
285
+ display.setCursor(2, 30);
286
+ printUpper(waitingLine2);
287
+
288
+ display.setCursor(2, 49);
289
+ display.print("SHORT // GO");
290
+
291
+ display.display();
292
+ }
293
+
294
+ void drawStatus() {
295
+ uiState = UI_STATUS;
296
+ if (!displayOk) return;
297
+
298
+ display.clearDisplay();
299
+ drawHeader();
300
+
301
+ display.setCursor(2, 16);
302
+ printUpper(line1);
303
+ display.setCursor(2, 30);
304
+ printUpper(line2);
305
+ display.setCursor(2, 44);
306
+ printUpper(line3);
307
+
308
+ if (progressPercent >= 0) {
309
+ int barW = map(progressPercent, 0, 100, 0, 124);
310
+ display.drawRect(2, 54, 124, 8, SSD1306_WHITE);
311
+ if (barW > 0) {
312
+ display.fillRect(2, 54, barW, 8, SSD1306_WHITE);
313
+ }
314
+ } else {
315
+ // Indeterminate progress — draw cycling dots
316
+ String dots = "";
317
+ for (int i = 0; i < indeterminateCount; i++) dots += ".";
318
+ display.setCursor(2, 56);
319
+ display.print(dots);
320
+ }
321
+
322
+ display.display();
323
+ }
324
+
325
+ void drawQrCode(String value) {
326
+ drawChrome();
327
+ QRCode qr;
328
+ uint8_t qrData[qrcode_getBufferSize(2)];
329
+ if (qrcode_initText(&qr, qrData, 2, ECC_LOW, value.c_str()) < 0) {
330
+ display.setCursor(2, 25);
331
+ display.print("QR ERROR // USE URL");
332
+ return;
333
+ }
334
+ const int scale = 2;
335
+ const int quiet = 2;
336
+ const int pixels = qr.size * scale;
337
+ const int left = (SCREEN_WIDTH - pixels) / 2;
338
+ const int top = 2;
339
+ display.fillRect(left - quiet, top - quiet, pixels + quiet * 2, pixels + quiet * 2, SSD1306_WHITE);
340
+ for (uint8_t y = 0; y < qr.size; y++) {
341
+ for (uint8_t x = 0; x < qr.size; x++) {
342
+ if (qrcode_getModule(&qr, x, y)) {
343
+ display.fillRect(left + x * scale, top + y * scale, scale, scale, SSD1306_BLACK);
344
+ }
345
+ }
346
+ }
347
+ display.setCursor(2, 57);
348
+ display.print("SCAN // DISCSTATION");
349
+ }
350
+
351
+ void drawIP() {
352
+ uiState = UI_IP;
353
+ returnToHomeAt = 0;
354
+ if (!displayOk) return;
355
+
356
+ display.clearDisplay();
357
+ drawQrCode(ipUrl);
358
+
359
+ display.display();
360
+ }
361
+
362
+ void drawStandby() {
363
+ uiState = UI_STANDBY;
364
+ returnToHomeAt = 0;
365
+ standbyStartTime = millis();
366
+ displayBlank = false;
367
+ if (!displayOk) return;
368
+
369
+ display.clearDisplay();
370
+ drawHeader();
371
+ display.setCursor(2, 25);
372
+ display.print("STANDBY // READY");
373
+ display.setCursor(2, 40);
374
+ display.print("INSERT MEDIA");
375
+ display.display();
376
+ }
377
+
378
+ void drawDisconnected() {
379
+ uiState = UI_DISCONNECTED;
380
+ returnToHomeAt = 0;
381
+ displayBlank = false;
382
+ if (!displayOk) return;
383
+
384
+ display.clearDisplay();
385
+ drawHeader();
386
+ display.setCursor(2, 25);
387
+ display.print("DISCONNECTED // LINK");
388
+ display.setCursor(2, 40);
389
+ display.print("CHECK USB");
390
+ display.display();
391
+ }
392
+
393
+ void wakeDisplay() {
394
+ if (!displayBlank || !displayOk) return;
395
+ display.ssd1306_command(0xAF);
396
+ displayBlank = false;
397
+ switch (uiState) {
398
+ case UI_HOME: drawHome(); break;
399
+ case UI_STATUS: drawStatus(); break;
400
+ case UI_IP: drawIP(); break;
401
+ case UI_BURN_READY: drawBurnReady(); break;
402
+ case UI_PLAY: drawPlay(); break;
403
+ case UI_WAITING: drawWaiting(); break;
404
+ case UI_STANDBY: drawStandby(); break;
405
+ case UI_DISCONNECTED: drawDisconnected(); break;
406
+ }
407
+ }
408
+
409
+ void drawPlay() {
410
+ uiState = UI_PLAY;
411
+ returnToHomeAt = 0;
412
+ if (!displayOk) return;
413
+
414
+ display.clearDisplay();
415
+ drawHeader();
416
+
417
+ display.setCursor(2, 16);
418
+ printUpper(line1);
419
+ display.setCursor(2, 30);
420
+ printUpper(line2);
421
+ display.setCursor(2, 44);
422
+ display.print("VOL // ");
423
+ display.print(playVolume);
424
+ display.print("%");
425
+ if (!audioPlayMode) {
426
+ display.setCursor(2, 56);
427
+ display.print("SELECT // PLAY");
428
+ }
429
+
430
+ display.display();
431
+ }
432
+
433
+ void parseMessage(String msg) {
434
+ msg.trim();
435
+
436
+ if (msg == "PING") {
437
+ lastMsgTime = millis();
438
+ Out.println("PONG");
439
+ if (uiState == UI_DISCONNECTED) drawStandby();
440
+ return;
441
+ }
442
+
443
+ wakeDisplay();
444
+ lastMsgTime = millis();
445
+
446
+ Out.print("RCV:");
447
+ Out.println(msg);
448
+
449
+ if (msg.startsWith("HOME:")) {
450
+ homeIndex = readBucket(homeCount);
451
+ drawHome();
452
+
453
+ } else if (msg.startsWith("DISC:")) {
454
+ discLine = msg.substring(5);
455
+ if (discLine.length() > 20) discLine = discLine.substring(0, 20);
456
+ if (uiState == UI_HOME) drawHome();
457
+
458
+ } else if (msg.startsWith("DISC_NAME:")) {
459
+ discName = msg.substring(10);
460
+ if (discName.length() > 18) discName = discName.substring(0, 18);
461
+ if (uiState == UI_HOME) drawHome();
462
+
463
+ } else if (msg.startsWith("MENU_ITEMS:")) {
464
+ String items = msg.substring(11);
465
+ homeCount = 0;
466
+ while (items.length() > 0 && homeCount < MAX_HOME_MODES) {
467
+ int comma = items.indexOf(',');
468
+ if (comma < 0) {
469
+ homeModes[homeCount++] = items;
470
+ break;
471
+ }
472
+ homeModes[homeCount++] = items.substring(0, comma);
473
+ items = items.substring(comma + 1);
474
+ }
475
+ if (homeIndex >= homeCount) homeIndex = 0;
476
+ if (uiState == UI_HOME) drawHome();
477
+
478
+ } else if (msg.startsWith("STANDBY:")) {
479
+ drawStandby();
480
+
481
+ } else if (msg.startsWith("TITLE:")) {
482
+ titleLine = msg.substring(6);
483
+ if (titleLine.length() > 20) titleLine = titleLine.substring(0, 20);
484
+ burnModeIndex = readBucket(BURN_COUNT);
485
+ drawBurnReady();
486
+
487
+ } else if (msg.startsWith("META:")) {
488
+ metaLine = msg.substring(5);
489
+ if (metaLine.length() > 20) metaLine = metaLine.substring(0, 20);
490
+ drawBurnReady();
491
+
492
+ } else if (msg.startsWith("FIT:")) {
493
+ fitLine = msg.substring(4);
494
+ if (fitLine.length() > 20) fitLine = fitLine.substring(0, 20);
495
+ drawBurnReady();
496
+
497
+ } else if (msg.startsWith("PLAY:")) {
498
+ line1 = msg.substring(5);
499
+ line2 = "Playing disc";
500
+ playVolume = readPercent();
501
+ drawPlay();
502
+
503
+ } else if (msg.startsWith("PLAY_MODE:")) {
504
+ audioPlayMode = msg.substring(10) == "AUDIO_CD";
505
+ drawPlay();
506
+
507
+ } else if (msg.startsWith("PLAY_STATUS:")) {
508
+ line1 = msg.substring(12);
509
+ if (line1.length() > 20) line1 = line1.substring(0, 20);
510
+ if (line1 == "PLAYING" || line1 == "PAUSED" || (audioPlayMode && line1.startsWith("TRACK "))) {
511
+ playStatusTemp = false;
512
+ } else {
513
+ playStatusAt = millis();
514
+ playStatusTemp = true;
515
+ }
516
+ drawPlay();
517
+
518
+ } else if (msg.startsWith("WAITING:")) {
519
+ returnToHomeAt = 0;
520
+ String rest = msg.substring(8);
521
+ int slash = rest.indexOf('/');
522
+ if (slash >= 0) {
523
+ waitingLine1 = "Data: " + rest.substring(0, slash);
524
+ waitingLine1.trim();
525
+ waitingLine2 = "Disc: " + rest.substring(slash + 1);
526
+ waitingLine2.trim();
527
+ } else {
528
+ waitingLine1 = rest;
529
+ waitingLine2 = "";
530
+ }
531
+ drawWaiting();
532
+
533
+ } else if (msg.startsWith("STATUS:")) {
534
+ returnToHomeAt = 0;
535
+ line1 = msg.substring(7);
536
+ line2 = "";
537
+ line3 = "";
538
+ progressPercent = -1;
539
+ drawStatus();
540
+
541
+ } else if (msg.startsWith("PROGRESS:")) {
542
+ returnToHomeAt = 0;
543
+ line2 = msg.substring(9);
544
+ {
545
+ String rest = msg.substring(9);
546
+ int pct = rest.indexOf('%');
547
+ if (pct > 0) {
548
+ String num = rest.substring(0, pct);
549
+ num.trim();
550
+ int val = num.toInt();
551
+ if (val >= 0 && val <= 100) {
552
+ progressPercent = val;
553
+ }
554
+ }
555
+ }
556
+ drawStatus();
557
+
558
+ } else if (msg.startsWith("INFO:")) {
559
+ returnToHomeAt = 0;
560
+ line3 = msg.substring(5);
561
+ drawStatus();
562
+
563
+ } else if (msg.startsWith("DONE:")) {
564
+ line1 = "DONE!";
565
+ line2 = msg.substring(5);
566
+ line3 = "Returning home";
567
+ returnToHomeAt = millis() + DONE_RESET_MS;
568
+ progressPercent = -1;
569
+ drawStatus();
570
+
571
+ } else if (msg.startsWith("CANCELLED:")) {
572
+ returnToHomeAt = 0;
573
+ line1 = "CANCELLED";
574
+ line2 = msg.substring(10);
575
+ line3 = "Use menu";
576
+ progressPercent = -1;
577
+ drawStatus();
578
+
579
+ } else if (msg.startsWith("WARNING:")) {
580
+ returnToHomeAt = 0;
581
+ line1 = "!! WARNING !!";
582
+ line2 = msg.substring(8);
583
+ if (line2.length() > 20) line2 = line2.substring(0, 20);
584
+ line3 = "";
585
+ drawStatus();
586
+
587
+ } else if (msg.startsWith("ERROR:")) {
588
+ returnToHomeAt = 0;
589
+ line1 = "!! ERROR";
590
+ line2 = msg.substring(6);
591
+ line3 = "";
592
+ progressPercent = -1;
593
+ drawStatus();
594
+
595
+ } else if (msg.startsWith("IP:")) {
596
+ ipUrl = msg.substring(3);
597
+ drawIP();
598
+ }
599
+ }
600
+
601
+ void setup() {
602
+ Serial.begin(115200);
603
+ esp_task_wdt_add(NULL);
604
+ Wire.begin(21, 22);
605
+ pinMode(BTN_SELECT_PIN, INPUT_PULLUP);
606
+ pinMode(BTN_UP_PIN, INPUT_PULLUP);
607
+ pinMode(BTN_DOWN_PIN, INPUT_PULLUP);
608
+ pinMode(LED_POWER_PIN, OUTPUT);
609
+ pinMode(LED_ACT_PIN, OUTPUT);
610
+ digitalWrite(LED_POWER_PIN, HIGH);
611
+ digitalWrite(LED_ACT_PIN, LOW);
612
+ pinMode(POT_PIN, INPUT);
613
+ analogReadResolution(12);
614
+ analogSetPinAttenuation(POT_PIN, ADC_11db);
615
+
616
+ homeIndex = readBucket(homeCount);
617
+ burnModeIndex = readBucket(BURN_COUNT);
618
+ playVolume = readPercent();
619
+
620
+ displayOk = display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS);
621
+
622
+ if (displayOk) {
623
+ Out.println("Display OK");
624
+ } else {
625
+ Out.println("Display FAILED");
626
+ delay(3000);
627
+ }
628
+
629
+ // Blink activity LED 3x to confirm it works
630
+ for (int i = 0; i < 3; i++) {
631
+ digitalWrite(LED_ACT_PIN, HIGH);
632
+ delay(150);
633
+ digitalWrite(LED_ACT_PIN, LOW);
634
+ delay(150);
635
+ }
636
+
637
+ drawStandby();
638
+ lastMsgTime = millis();
639
+ Out.println("DISCSTATION_READY");
640
+ }
641
+
642
+ void handleSelectPress(bool longPress) {
643
+ wakeDisplay();
644
+ if (uiState == UI_HOME) {
645
+ if (longPress) {
646
+ Out.println("EJECT");
647
+ line1 = "Ejecting...";
648
+ line2 = "";
649
+ line3 = "";
650
+ returnToHomeAt = millis() + 5000;
651
+ drawStatus();
652
+ } else if (homeIndex >= 0 && homeIndex < homeCount) {
653
+ Out.print("SELECT:");
654
+ Out.println(homeModes[homeIndex]);
655
+ line1 = "Selected";
656
+ line2 = homeModes[homeIndex];
657
+ line3 = "";
658
+ drawStatus();
659
+ }
660
+
661
+ } else if (uiState == UI_STANDBY) {
662
+ Out.println("EJECT");
663
+ line1 = "Ejecting...";
664
+ line2 = "";
665
+ line3 = "";
666
+ returnToHomeAt = millis() + 5000;
667
+ drawStatus();
668
+
669
+ } else if (uiState == UI_STATUS && line1 == "Ejecting...") {
670
+ // Manual escape if stuck on eject screen with no app response
671
+ drawStandby();
672
+
673
+ } else if (uiState == UI_IP) {
674
+ if (longPress) {
675
+ Out.println("CANCEL");
676
+ drawHome();
677
+ }
678
+
679
+ } else if (uiState == UI_WAITING) {
680
+ if (longPress) {
681
+ Out.println("CANCEL");
682
+ drawHome();
683
+ } else {
684
+ Out.println("CONFIRM");
685
+ line1 = "Confirmed!";
686
+ line2 = "";
687
+ line3 = "";
688
+ drawStatus();
689
+ }
690
+
691
+ } else if (uiState == UI_BURN_READY) {
692
+ if (longPress) {
693
+ Out.print("START:");
694
+ Out.println(BURN_MODES[burnModeIndex]);
695
+ line1 = "Starting...";
696
+ line2 = BURN_MODES[burnModeIndex];
697
+ line3 = "";
698
+ drawStatus();
699
+ } else {
700
+ editingSpeed = !editingSpeed;
701
+ drawBurnReady();
702
+ }
703
+
704
+ } else if (uiState == UI_STATUS) {
705
+ if (longPress) {
706
+ Out.println("CANCEL");
707
+ line1 = "Cancelling...";
708
+ line2 = "";
709
+ line3 = "";
710
+ drawStatus();
711
+ }
712
+
713
+ } else if (uiState == UI_DISCONNECTED) {
714
+ Out.println("CANCEL");
715
+ drawStandby();
716
+
717
+ } else if (uiState == UI_STANDBY) {
718
+ displayRotation = 2;
719
+ drawStandby();
720
+ } else if (uiState == UI_PLAY) {
721
+ ffSpeedIndex = 0;
722
+ rewSpeedIndex = 0;
723
+ if (longPress) {
724
+ Out.println("PLAY_STOP");
725
+ } else {
726
+ Out.println("PLAY_BUTTON");
727
+ }
728
+ }
729
+ }
730
+
731
+ void handleUp(bool longPress) {
732
+ wakeDisplay();
733
+ if (uiState == UI_HOME) {
734
+ if (homeCount > 0) {
735
+ homeIndex = (homeIndex - 1 + homeCount) % homeCount;
736
+ sendHomeMode();
737
+ drawHome();
738
+ }
739
+ } else if (uiState == UI_BURN_READY) {
740
+ if (editingSpeed) {
741
+ burnSpeedIndex = (burnSpeedIndex - 1 + SPEED_COUNT) % SPEED_COUNT;
742
+ Out.print("SPEED:");
743
+ Out.println(SPEED_MODES[burnSpeedIndex]);
744
+ drawBurnReady();
745
+ } else {
746
+ burnModeIndex = (burnModeIndex - 1 + BURN_COUNT) % BURN_COUNT;
747
+ sendBurnMode();
748
+ drawBurnReady();
749
+ }
750
+ } else if (uiState == UI_STANDBY) {
751
+ displayRotation = 0;
752
+ drawStandby();
753
+ } else if (uiState == UI_PLAY) {
754
+ if (audioPlayMode) {
755
+ Out.println(displayRotation == 0 ? (longPress ? "REW:BIG" : "REW:10") : (longPress ? "FF:BIG" : "FF:10"));
756
+ return;
757
+ }
758
+ rewSpeedIndex = 0;
759
+ if (longPress) {
760
+ Out.println("FF:BIG");
761
+ } else {
762
+ ffSpeedIndex = (ffSpeedIndex + 1) % 4;
763
+ int seek_sec[] = {10, 20, 30, 60};
764
+ Out.print("FF:");
765
+ Out.println(seek_sec[ffSpeedIndex]);
766
+ }
767
+ }
768
+ }
769
+
770
+ void handleDown(bool longPress) {
771
+ wakeDisplay();
772
+ if (uiState == UI_HOME) {
773
+ if (homeCount > 0) {
774
+ homeIndex = (homeIndex + 1) % homeCount;
775
+ sendHomeMode();
776
+ drawHome();
777
+ }
778
+ } else if (uiState == UI_BURN_READY) {
779
+ if (editingSpeed) {
780
+ burnSpeedIndex = (burnSpeedIndex + 1) % SPEED_COUNT;
781
+ Out.print("SPEED:");
782
+ Out.println(SPEED_MODES[burnSpeedIndex]);
783
+ drawBurnReady();
784
+ } else {
785
+ burnModeIndex = (burnModeIndex + 1) % BURN_COUNT;
786
+ sendBurnMode();
787
+ drawBurnReady();
788
+ }
789
+ } else if (uiState == UI_STANDBY) {
790
+ displayRotation = 2;
791
+ drawStandby();
792
+ } else if (uiState == UI_PLAY) {
793
+ if (audioPlayMode) {
794
+ Out.println(displayRotation == 0 ? (longPress ? "FF:BIG" : "FF:10") : (longPress ? "REW:BIG" : "REW:10"));
795
+ return;
796
+ }
797
+ ffSpeedIndex = 0;
798
+ if (longPress) {
799
+ Out.println("REW:BIG");
800
+ } else {
801
+ rewSpeedIndex = (rewSpeedIndex + 1) % 4;
802
+ int seek_sec[] = {10, 20, 30, 60};
803
+ Out.print("REW:");
804
+ Out.println(seek_sec[rewSpeedIndex]);
805
+ }
806
+ }
807
+ }
808
+
809
+ void loop() {
810
+ if (!wifiInitDone && millis() > 3000) {
811
+ wifiInitDone = true;
812
+ initWiFi();
813
+ }
814
+ if (wifiConnected) {
815
+ ArduinoOTA.handle();
816
+ if (tcpServer.hasClient()) {
817
+ if (tcpClient) tcpClient.stop();
818
+ tcpClient = tcpServer.available();
819
+ Out.setClient(&tcpClient);
820
+ }
821
+ if (tcpClient && !tcpClient.connected()) {
822
+ tcpClient.stop();
823
+ Out.setClient(nullptr);
824
+ }
825
+ if (tcpClient && tcpClient.available()) {
826
+ String msg = tcpClient.readStringUntil('\n');
827
+ if (msg.length() > 0) parseMessage(msg);
828
+ }
829
+ }
830
+
831
+ esp_task_wdt_reset();
832
+ if (Serial.available()) {
833
+ String msg = Serial.readStringUntil('\n');
834
+ parseMessage(msg);
835
+ }
836
+
837
+ if (returnToHomeAt != 0 && (long)(millis() - returnToHomeAt) >= 0) {
838
+ drawHome();
839
+ }
840
+
841
+ if (playStatusTemp && (long)(millis() - playStatusAt) >= 2500) {
842
+ playStatusTemp = false;
843
+ line1 = "PLAYING";
844
+ drawPlay();
845
+ }
846
+
847
+ // --- Pot reading (PLAY only, volume) ---
848
+ if (uiState == UI_PLAY && millis() - lastPotRead > POT_READ_MS) {
849
+ lastPotRead = millis();
850
+ int nextVolume = readPercent();
851
+ if (abs(nextVolume - playVolume) >= 3) {
852
+ playVolume = nextVolume;
853
+ Out.print("POT:");
854
+ Out.println(playVolume);
855
+ drawPlay();
856
+ }
857
+ }
858
+
859
+ // --- SELECT button ---
860
+ {
861
+ bool sel = digitalRead(BTN_SELECT_PIN) == LOW;
862
+ if (sel && !selectDown && millis() - selectLastDebounce > DEBOUNCE_MS) {
863
+ selectDown = true;
864
+ selectDownAt = millis();
865
+ }
866
+ if (!sel && selectDown) {
867
+ selectDown = false;
868
+ selectLastDebounce = millis();
869
+ handleSelectPress(millis() - selectDownAt >= LONG_PRESS_MS);
870
+ }
871
+ }
872
+
873
+ // --- UP button ---
874
+ {
875
+ bool up = digitalRead(BTN_UP_PIN) == LOW;
876
+ if (up && !upDown && millis() - upLastDebounce > DEBOUNCE_MS) {
877
+ upDown = true;
878
+ upDownAt = millis();
879
+ }
880
+ if (!up && upDown) {
881
+ upDown = false;
882
+ upLastDebounce = millis();
883
+ bool lp = millis() - upDownAt >= LONG_PRESS_MS;
884
+ if (uiState == UI_PLAY || !lp) {
885
+ handleUp(lp);
886
+ }
887
+ }
888
+ }
889
+
890
+ // --- DOWN button ---
891
+ {
892
+ bool dn = digitalRead(BTN_DOWN_PIN) == LOW;
893
+ if (dn && !downDown && millis() - downLastDebounce > DEBOUNCE_MS) {
894
+ downDown = true;
895
+ downDownAt = millis();
896
+ }
897
+ if (!dn && downDown) {
898
+ downDown = false;
899
+ downLastDebounce = millis();
900
+ bool lp = millis() - downDownAt >= LONG_PRESS_MS;
901
+ if (uiState == UI_PLAY || !lp) {
902
+ handleDown(lp);
903
+ }
904
+ }
905
+ }
906
+
907
+ // --- Indeterminate progress animation ---
908
+ if (uiState == UI_STATUS && progressPercent < 0 &&
909
+ millis() - lastIndeterminateAnim > 500) {
910
+ lastIndeterminateAnim = millis();
911
+ indeterminateCount = (indeterminateCount % 4) + 1;
912
+ if (!displayBlank) drawStatus();
913
+ }
914
+
915
+ // --- Standby blanking ---
916
+ if (displayOk && uiState == UI_STANDBY && !displayBlank &&
917
+ (long)(millis() - standbyStartTime) >= STANDBY_BLANK_MS) {
918
+ display.ssd1306_command(0xAE);
919
+ displayBlank = true;
920
+ digitalWrite(LED_POWER_PIN, LOW);
921
+ }
922
+ if (!displayBlank) {
923
+ digitalWrite(LED_POWER_PIN, HIGH);
924
+ }
925
+
926
+ // --- PING timeout (disconnected) ---
927
+ if (uiState != UI_DISCONNECTED &&
928
+ (long)(millis() - lastMsgTime) >= PING_TIMEOUT_MS) {
929
+ drawDisconnected();
930
+ }
931
+
932
+ // --- Activity LED ---
933
+ {
934
+ bool ledOn = false;
935
+ if (uiState == UI_BURN_READY || uiState == UI_PLAY || uiState == UI_WAITING || uiState == UI_IP) {
936
+ ledOn = true;
937
+ } else if (uiState == UI_STATUS) {
938
+ if (progressPercent >= 0) {
939
+ ledOn = (millis() - lastLedBlink > LED_BLINK_MS);
940
+ if (ledOn) {
941
+ lastLedBlink = millis();
942
+ actLedState = !actLedState;
943
+ }
944
+ ledOn = actLedState;
945
+ } else {
946
+ ledOn = true;
947
+ }
948
+ } else if (uiState == UI_HOME || uiState == UI_STANDBY) {
949
+ ledOn = false;
950
+ } else if (uiState == UI_DISCONNECTED) {
951
+ ledOn = true;
952
+ }
953
+ digitalWrite(LED_ACT_PIN, ledOn ? HIGH : LOW);
954
+ }
955
+
956
+ delay(20);
957
+ }