discstation 0.1.42 → 0.2.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.
- package/README.md +14 -6
- package/arduino/firmware/discstation.bin +0 -0
- package/arduino/firmware/firmware.json +1 -1
- package/package.json +1 -1
- package/src/discstation.py +110 -16
- package/src/discstation_burn.py +25 -7
- package/src/static/app.js +227 -31
- package/src/static/index.html +19 -13
- package/src/static/style.css +104 -2
package/README.md
CHANGED
|
@@ -71,7 +71,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
|
|
|
71
71
|
|------|-------------|
|
|
72
72
|
| **Burn Video DVD** | YouTube URL or local file → ffmpeg 2-pass → DVD-Video disc |
|
|
73
73
|
| **Burn Data DVD** | Any files/folders → ISO/Joliet data disc (no quality loss) |
|
|
74
|
-
| **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — the OLED remote
|
|
74
|
+
| **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — both the OLED remote and the web/app on-screen remote show a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`); while a track plays the screen keeps the track/volume view (or the visualizer) up — the spinning-disc screensaver only appears on the home/standby screens or after playback has been paused for 15 s |
|
|
75
75
|
| **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
|
|
76
76
|
|
|
77
77
|
## Project Structure
|
|
@@ -172,10 +172,17 @@ self-signed cert. On startup the host prints the LAN URL to open
|
|
|
172
172
|
tag next to the wordmark any time). Mode buttons only show what the disc
|
|
173
173
|
in the drive can actually do — a blank CD offers BURN DATA/BURN AUDIO, a
|
|
174
174
|
blank DVD offers BURN/BURN DATA, an already-recorded disc offers PLAY/RIP
|
|
175
|
-
— and picking one starts it directly, no separate confirm step.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
175
|
+
— and picking one starts it directly, no separate confirm step. Its
|
|
176
|
+
screen mirrors the physical OLED remote: the disc title/status and offered
|
|
177
|
+
modes at idle, and a glowing peak-hold spectrum visualizer that fills the
|
|
178
|
+
width of the screen during playback (see Play, above). EJECT/CLOSE toggle,
|
|
179
|
+
volume, and full playback transport including PREV/NEXT track for audio
|
|
180
|
+
CDs. Greys out and auto-collapses the instant a physical remote is
|
|
181
|
+
detected, so the two never fight for control — including mid-burn/rip/play:
|
|
182
|
+
plugging the remote in hands it control without interrupting whatever's
|
|
183
|
+
running, and its screen picks up the current status instead of starting
|
|
184
|
+
over; unplugging it hands control back to the on-screen remote within a
|
|
185
|
+
second or two.
|
|
179
186
|
|
|
180
187
|
## Mobile App
|
|
181
188
|
|
|
@@ -184,7 +191,8 @@ has the same on-screen remote as the web UI, rendered inline in the main
|
|
|
184
191
|
screen rather than a popup — same "REMOTE" tag toggle, opens automatically
|
|
185
192
|
with no ESP32 attached, mode buttons filtered to what the disc actually
|
|
186
193
|
supports, and picking one starts it directly. Same auto-collapse behavior
|
|
187
|
-
when a physical ESP32 shows up
|
|
194
|
+
when a physical ESP32 shows up — mid-operation and without interruption, same
|
|
195
|
+
as the web UI — and the same underlying `/remote/button` protocol.
|
|
188
196
|
|
|
189
197
|
## Disc Support
|
|
190
198
|
|
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
1
|
+
{"version":"0.2.0","size":1260592,"md5":"94fb8a04146735daa1824da53304702b"}
|
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -58,7 +58,7 @@ _web_status = "READY"
|
|
|
58
58
|
_web_progress = -1
|
|
59
59
|
_web_progress_active = False
|
|
60
60
|
_web_playing = False # a play_flow is currently active (transport controls apply)
|
|
61
|
-
|
|
61
|
+
_web_phase = "READY" # most recent STATUS: text, prefixed onto the next PROGRESS: value
|
|
62
62
|
_operation_active = False # a burn/rip/play flow is holding the drive
|
|
63
63
|
_last_disc_info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
64
64
|
_active_ser = None
|
|
@@ -460,6 +460,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
460
460
|
url = params.get('url', [''])[0].strip()
|
|
461
461
|
if url:
|
|
462
462
|
_burn_url_queue.put(url)
|
|
463
|
+
self._chain_start_if_waiting()
|
|
463
464
|
self._respond(200, 'URL received. Starting burn...')
|
|
464
465
|
else:
|
|
465
466
|
self._respond(400, 'Missing URL')
|
|
@@ -505,6 +506,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
505
506
|
# the queue forever, waiting to wrongly satisfy a later, unrelated
|
|
506
507
|
# burn attempt.)
|
|
507
508
|
_burn_url_queue.put(str(upload_dir))
|
|
509
|
+
self._chain_start_if_waiting()
|
|
508
510
|
size_str = f"{total / 1e6:.1f}MB" if total > 1e6 else f"{total / 1e3:.0f}KB"
|
|
509
511
|
_set_web_progress("UPLOAD READY", 100)
|
|
510
512
|
# Used to hardcode "Select BURN DATA on remote" - wrong mode name for
|
|
@@ -530,10 +532,32 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
530
532
|
return
|
|
531
533
|
if _active_ser is not None and _active_ser.hw is None:
|
|
532
534
|
_active_ser.virtual.push_line(cmd)
|
|
535
|
+
# One-click burn, half of it: if a source (upload/URL) was already
|
|
536
|
+
# queued *before* this mode was selected, chain START right behind
|
|
537
|
+
# it now - the other half is _chain_start_if_waiting(), fired when
|
|
538
|
+
# the source arrives *after* mode selection instead (the web UI's
|
|
539
|
+
# actual normal order for a video burn: pick BURN, then paste a
|
|
540
|
+
# URL). Chaining START unconditionally behind every SELECT:BURN*
|
|
541
|
+
# used to lose it whenever the source wasn't ready yet: it sent
|
|
542
|
+
# START straight into wait_for_web_url()'s own CANCEL/HOME-polling
|
|
543
|
+
# loop, which reads and silently discards whatever isn't CANCEL/HOME
|
|
544
|
+
# while it waits - the exact "burn never starts" bug, just for the
|
|
545
|
+
# opposite ordering than the one already fixed once before.
|
|
546
|
+
if cmd.upper().startswith("SELECT:BURN") and not _burn_url_queue.empty():
|
|
547
|
+
_active_ser.virtual.push_line("START")
|
|
533
548
|
self._respond(200, 'OK')
|
|
534
549
|
else:
|
|
535
550
|
self._respond(409, 'A hardware remote is attached')
|
|
536
551
|
|
|
552
|
+
def _chain_start_if_waiting(self):
|
|
553
|
+
"""The other half of one-click burn (see _handle_remote_button): a
|
|
554
|
+
source arriving while a mode is already selected and waiting for one
|
|
555
|
+
(_operation_active) means this is the signal to actually start -
|
|
556
|
+
pushed here, at the moment the source becomes real, instead of
|
|
557
|
+
earlier at mode-select time when it might not exist yet."""
|
|
558
|
+
if _operation_active and _active_ser is not None and _active_ser.hw is None:
|
|
559
|
+
_active_ser.virtual.push_line("START")
|
|
560
|
+
|
|
537
561
|
def _serve_firmware(self):
|
|
538
562
|
image, _ = _firmware_image()
|
|
539
563
|
if not image:
|
|
@@ -591,6 +615,10 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
591
615
|
info["type"] = di.web_type
|
|
592
616
|
info["kind"] = di.kind
|
|
593
617
|
info["label"] = di.label
|
|
618
|
+
# Same lookup the OLED's DISC_NAME: already uses (TOC-fingerprint match
|
|
619
|
+
# against burn history for an audio CD, volume label for a data disc/DVD) -
|
|
620
|
+
# richer than "label" above, which is empty for most audio CDs.
|
|
621
|
+
info["disc_title"] = disc_title(device) if di.present and not di.transient else ""
|
|
594
622
|
info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
|
|
595
623
|
except Exception as e:
|
|
596
624
|
print(f"Disc info error: {e}")
|
|
@@ -645,7 +673,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
645
673
|
def _serve_sw(self):
|
|
646
674
|
sw = '''self.addEventListener('install', e => {
|
|
647
675
|
self.skipWaiting();
|
|
648
|
-
caches.open('discstation-
|
|
676
|
+
caches.open('discstation-v46').then(c => c.addAll(['/','/static/style.css?v=46','/static/app.js?v=46']));
|
|
649
677
|
});
|
|
650
678
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
651
679
|
self.addEventListener('fetch', e => {
|
|
@@ -654,7 +682,7 @@ self.addEventListener('fetch', e => {
|
|
|
654
682
|
if (path === '/' || path.startsWith('/static/')) {
|
|
655
683
|
e.respondWith(fetch(e.request).then(r => {
|
|
656
684
|
const copy = r.clone();
|
|
657
|
-
caches.open('discstation-
|
|
685
|
+
caches.open('discstation-v46').then(c => c.put(e.request, copy));
|
|
658
686
|
return r;
|
|
659
687
|
}).catch(() => caches.match(e.request)));
|
|
660
688
|
} else {
|
|
@@ -845,6 +873,20 @@ def wait_for_web_url(ser):
|
|
|
845
873
|
url = f"{protocol}://{ip}:{_web_port}"
|
|
846
874
|
safe_send(ser, f"IP:{url}")
|
|
847
875
|
print(f"URL displayed: {url}")
|
|
876
|
+
# The one-click remote buttons queue START right behind SELECT:BURN* (see
|
|
877
|
+
# _handle_remote_button) - when files were uploaded BEFORE the mode was
|
|
878
|
+
# picked (the normal one-click order), that START can already be sitting
|
|
879
|
+
# in the buffer by the time this runs. Check the queue before touching the
|
|
880
|
+
# serial line at all: the loop below reads and discards one line per pass
|
|
881
|
+
# looking for CANCEL/HOME, and would otherwise eat that START and throw it
|
|
882
|
+
# away, leaving the burn waiting forever for a START that already came.
|
|
883
|
+
try:
|
|
884
|
+
got = _burn_url_queue.get_nowait()
|
|
885
|
+
except Empty:
|
|
886
|
+
got = None
|
|
887
|
+
if got:
|
|
888
|
+
safe_send(ser, "STATUS:Got URL, starting...")
|
|
889
|
+
return got
|
|
848
890
|
while True:
|
|
849
891
|
line = read_serial_line(ser, timeout=0.5)
|
|
850
892
|
if line and line.strip().upper() in ("CANCEL", "HOME"):
|
|
@@ -1055,7 +1097,7 @@ def _set_web_progress(phase, percent=-1):
|
|
|
1055
1097
|
|
|
1056
1098
|
|
|
1057
1099
|
def _record_web_status(msg):
|
|
1058
|
-
global _web_status, _web_progress, _web_progress_active, _web_playing
|
|
1100
|
+
global _web_status, _web_progress, _web_progress_active, _web_playing, _web_phase
|
|
1059
1101
|
_remember(msg)
|
|
1060
1102
|
if msg.startswith("DISC:"):
|
|
1061
1103
|
_sse_publish({"type": "disc-changed"})
|
|
@@ -1066,12 +1108,20 @@ def _record_web_status(msg):
|
|
|
1066
1108
|
_web_progress_active = True
|
|
1067
1109
|
elif msg.startswith("PLAY_STATUS:") or msg.startswith("PLAY:"):
|
|
1068
1110
|
_web_status = msg.split(":", 1)[1].strip() or "PLAYING"
|
|
1111
|
+
elif msg.startswith("WAITING:"):
|
|
1112
|
+
# The OLED's pre-burn confirm screen ("does this fit? confirm to
|
|
1113
|
+
# go") - was silently dropped before (not in this elif chain at
|
|
1114
|
+
# all), which is exactly why a burn stuck here looked frozen on
|
|
1115
|
+
# the web instead of showing what it's actually waiting on.
|
|
1116
|
+
_web_status = "Waiting to confirm: " + (msg[8:].strip() or "...")
|
|
1117
|
+
_web_progress_active = True
|
|
1069
1118
|
elif msg.startswith("STATUS:"):
|
|
1070
1119
|
_web_status = msg[7:].strip() or "READY"
|
|
1120
|
+
_web_phase = _web_status.rstrip(".")
|
|
1071
1121
|
_web_progress_active = True
|
|
1072
1122
|
elif msg.startswith("PROGRESS:"):
|
|
1073
1123
|
value = msg[9:].strip()
|
|
1074
|
-
_web_status = f"{
|
|
1124
|
+
_web_status = f"{_web_phase} {value}"
|
|
1075
1125
|
match = re.search(r"(\d+(?:\.\d+)?)", value)
|
|
1076
1126
|
if match:
|
|
1077
1127
|
_web_progress = min(100, max(0, int(float(match.group(1)))))
|
|
@@ -1081,14 +1131,17 @@ def _record_web_status(msg):
|
|
|
1081
1131
|
_web_progress = 100
|
|
1082
1132
|
_web_progress_active = False
|
|
1083
1133
|
_web_playing = False
|
|
1134
|
+
_web_phase = "READY"
|
|
1084
1135
|
elif msg.startswith("ERROR:"):
|
|
1085
1136
|
_web_status = msg[6:].strip() or "ERROR"
|
|
1086
1137
|
_web_progress_active = False
|
|
1087
1138
|
_web_playing = False
|
|
1139
|
+
_web_phase = "READY"
|
|
1088
1140
|
elif msg.startswith("CANCELLED:"):
|
|
1089
1141
|
_web_status = msg[10:].strip() or "CANCELLED"
|
|
1090
1142
|
_web_progress_active = False
|
|
1091
1143
|
_web_playing = False
|
|
1144
|
+
_web_phase = "READY"
|
|
1092
1145
|
elif msg.startswith(("STANDBY:", "HOME:")):
|
|
1093
1146
|
# idle again (tray open, insert disc, back to the menu) — clear any
|
|
1094
1147
|
# lingering "Ejecting..." / progress state on the web UI.
|
|
@@ -1097,6 +1150,7 @@ def _record_web_status(msg):
|
|
|
1097
1150
|
_web_progress = -1
|
|
1098
1151
|
_web_progress_active = False
|
|
1099
1152
|
_web_playing = False
|
|
1153
|
+
_web_phase = "READY"
|
|
1100
1154
|
else:
|
|
1101
1155
|
return
|
|
1102
1156
|
_sse_publish(_status_snapshot())
|
|
@@ -1482,14 +1536,17 @@ def _wait_for_start(ser, starting=None, mode=None):
|
|
|
1482
1536
|
|
|
1483
1537
|
def run_probe(cmd, timeout=8, name=None):
|
|
1484
1538
|
try:
|
|
1485
|
-
return
|
|
1486
|
-
cmd,
|
|
1487
|
-
|
|
1488
|
-
text=True,
|
|
1489
|
-
timeout=timeout,
|
|
1539
|
+
return _run_hard_timeout(
|
|
1540
|
+
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
|
|
1541
|
+
timeout=timeout + 5,
|
|
1490
1542
|
)
|
|
1491
1543
|
except subprocess.TimeoutExpired as e:
|
|
1492
1544
|
return subprocess.CompletedProcess(cmd, 124, ensure_text(e.stdout), ensure_text(e.stderr))
|
|
1545
|
+
except RuntimeError:
|
|
1546
|
+
# _run_hard_timeout gave up on a child stuck in D-state (uninterruptible
|
|
1547
|
+
# kernel I/O sleep) rather than a clean subprocess-level timeout - same
|
|
1548
|
+
# "this probe didn't answer" outcome from every caller's point of view.
|
|
1549
|
+
return subprocess.CompletedProcess(cmd, 124, "", "")
|
|
1493
1550
|
except (FileNotFoundError, OSError) as e:
|
|
1494
1551
|
return subprocess.CompletedProcess(cmd, 127, "", str(e))
|
|
1495
1552
|
|
|
@@ -1578,6 +1635,9 @@ def udev_cdrom_properties(device, refresh=False):
|
|
|
1578
1635
|
return properties
|
|
1579
1636
|
|
|
1580
1637
|
|
|
1638
|
+
discstation_burn.udev_cdrom_properties = udev_cdrom_properties
|
|
1639
|
+
|
|
1640
|
+
|
|
1581
1641
|
_tray_open = False
|
|
1582
1642
|
_tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
|
|
1583
1643
|
|
|
@@ -2176,6 +2236,32 @@ def menu_items_for_disc(device):
|
|
|
2176
2236
|
return items
|
|
2177
2237
|
|
|
2178
2238
|
|
|
2239
|
+
def _run_hard_timeout(fn, timeout):
|
|
2240
|
+
"""Run fn() on a throwaway daemon thread and bound the wait on the THREAD,
|
|
2241
|
+
not on whatever fn() calls internally. subprocess.run(timeout=N) can't
|
|
2242
|
+
rescue a caller from a child stuck in D-state (uninterruptible kernel I/O
|
|
2243
|
+
sleep - e.g. a drive that can't read a bad/dirty disc): SIGKILL doesn't
|
|
2244
|
+
interrupt D-state, so the library's own kill-then-reap after its own
|
|
2245
|
+
timeout hangs right along with it. This is the only way to guarantee the
|
|
2246
|
+
caller gets control back - the thread (and whatever it's still stuck in)
|
|
2247
|
+
is abandoned/leaked, but nothing ever waits on it again, so any lock the
|
|
2248
|
+
caller holds gets released either way."""
|
|
2249
|
+
result = {}
|
|
2250
|
+
def _target():
|
|
2251
|
+
try:
|
|
2252
|
+
result["value"] = fn()
|
|
2253
|
+
except Exception as e:
|
|
2254
|
+
result["error"] = e
|
|
2255
|
+
t = threading.Thread(target=_target, daemon=True)
|
|
2256
|
+
t.start()
|
|
2257
|
+
t.join(timeout)
|
|
2258
|
+
if t.is_alive():
|
|
2259
|
+
raise RuntimeError("drive not responding (timed out)")
|
|
2260
|
+
if "error" in result:
|
|
2261
|
+
raise result["error"]
|
|
2262
|
+
return result["value"]
|
|
2263
|
+
|
|
2264
|
+
|
|
2179
2265
|
class mounted_disc:
|
|
2180
2266
|
def __init__(self, device):
|
|
2181
2267
|
self.device = device
|
|
@@ -2206,7 +2292,14 @@ class mounted_disc:
|
|
|
2206
2292
|
else:
|
|
2207
2293
|
self.tmp.cleanup()
|
|
2208
2294
|
raise RuntimeError("Disc mounting backend is not configured for this operating system")
|
|
2209
|
-
|
|
2295
|
+
try:
|
|
2296
|
+
result = _run_hard_timeout(
|
|
2297
|
+
lambda: subprocess.run(command, capture_output=True, text=True, timeout=30),
|
|
2298
|
+
timeout=35,
|
|
2299
|
+
)
|
|
2300
|
+
except Exception:
|
|
2301
|
+
self.tmp.cleanup()
|
|
2302
|
+
raise
|
|
2210
2303
|
if result.returncode != 0:
|
|
2211
2304
|
self.tmp.cleanup()
|
|
2212
2305
|
raise RuntimeError((result.stderr or result.stdout or "Could not mount disc").strip())
|
|
@@ -2958,7 +3051,7 @@ def burn_flow(ser):
|
|
|
2958
3051
|
)
|
|
2959
3052
|
|
|
2960
3053
|
if plan["burn"]:
|
|
2961
|
-
discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes)
|
|
3054
|
+
discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes, auto_confirm=ser.hw is None)
|
|
2962
3055
|
discstation_burn.burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
2963
3056
|
safe_send(ser, "DONE:Disc complete!")
|
|
2964
3057
|
print("Burn complete.")
|
|
@@ -3227,8 +3320,8 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3227
3320
|
if proc.poll() is not None:
|
|
3228
3321
|
break
|
|
3229
3322
|
continue
|
|
3230
|
-
if pause_event.is_set() or ser.hw is None
|
|
3231
|
-
continue
|
|
3323
|
+
if pause_event.is_set() or (ser.hw is None and not _sse_subs):
|
|
3324
|
+
continue # nobody to draw bars for - no OLED, no browser watching either
|
|
3232
3325
|
samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
|
|
3233
3326
|
spectrum = _np.abs(_np.fft.rfft(samples * window))
|
|
3234
3327
|
mags = []
|
|
@@ -3253,6 +3346,8 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3253
3346
|
shown[i] = target if target > shown[i] else max(0.0, shown[i] - VU_PEAK_DECAY)
|
|
3254
3347
|
levels.append(int(shown[i]))
|
|
3255
3348
|
send(ser, "VU:" + ",".join(str(v) for v in levels))
|
|
3349
|
+
if _sse_subs: # skip the JSON encode when nobody's listening
|
|
3350
|
+
_sse_publish({"type": "vu", "levels": levels})
|
|
3256
3351
|
finally:
|
|
3257
3352
|
discstation_burn.stop_process(proc)
|
|
3258
3353
|
|
|
@@ -4255,7 +4350,7 @@ def rip_audio_cd(ser, device):
|
|
|
4255
4350
|
|
|
4256
4351
|
|
|
4257
4352
|
def station_loop(ser):
|
|
4258
|
-
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
|
|
4353
|
+
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
|
|
4259
4354
|
discstation_burn.cleanup_old_jobs()
|
|
4260
4355
|
try:
|
|
4261
4356
|
device = discstation_burn.disc_device()
|
|
@@ -4476,7 +4571,6 @@ def station_loop(ser):
|
|
|
4476
4571
|
|
|
4477
4572
|
mode = line.split(":", 1)[1].strip().upper()
|
|
4478
4573
|
print(f"Selected: {mode}")
|
|
4479
|
-
_web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
|
|
4480
4574
|
# The web remote's mode buttons queue START right behind SELECT: for a
|
|
4481
4575
|
# one-click burn (see app.js) - if an earlier selection was abandoned
|
|
4482
4576
|
# before its own START got consumed (e.g. never uploaded/confirmed a
|
package/src/discstation_burn.py
CHANGED
|
@@ -370,12 +370,17 @@ def disc_device():
|
|
|
370
370
|
|
|
371
371
|
def _udevadm_props(device):
|
|
372
372
|
# Prefer the shared implementation in discstation (pyudev-backed, with a
|
|
373
|
-
# cdrom_id refresh)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
373
|
+
# cdrom_id refresh), injected as udev_cdrom_properties below (same pattern
|
|
374
|
+
# as status_sink) - `from discstation import ...` here used to re-import
|
|
375
|
+
# the entry script under a second module name ("discstation" alongside
|
|
376
|
+
# the real "__main__"), silently duplicating every module-level global
|
|
377
|
+
# (_web_status et al) and leaving send()'s status_sink rebound to the
|
|
378
|
+
# duplicate's dead copy, so /progress never saw another update again.
|
|
379
|
+
if udev_cdrom_properties is not None:
|
|
380
|
+
try:
|
|
381
|
+
return udev_cdrom_properties(device)
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
379
384
|
if discstation_host.system_name() != "linux":
|
|
380
385
|
return discstation_host.media_properties(device)
|
|
381
386
|
try:
|
|
@@ -519,6 +524,11 @@ def detect_disc_type(device):
|
|
|
519
524
|
# pipeline emits also updates the web/SSE status in real time.
|
|
520
525
|
status_sink = None
|
|
521
526
|
|
|
527
|
+
# discstation.py sets this to its own udev_cdrom_properties (pyudev-backed,
|
|
528
|
+
# with a cdrom_id refresh) - see the comment in _udevadm_props for why this
|
|
529
|
+
# can't just be a lazy `import discstation` instead.
|
|
530
|
+
udev_cdrom_properties = None
|
|
531
|
+
|
|
522
532
|
# discstation.py sets this to its buffered read_serial_line so check_cancel
|
|
523
533
|
# reads the same way station_loop does (partial lines get buffered and
|
|
524
534
|
# reassembled). A bare ser.readline() here can slice a "CANCEL\n" in half
|
|
@@ -1180,7 +1190,13 @@ def check_dvd_size(ser, dvd_dir, disc_bytes=None):
|
|
|
1180
1190
|
)
|
|
1181
1191
|
return size
|
|
1182
1192
|
|
|
1183
|
-
def wait_for_burn_confirm(ser, dvd_dir, disc_capacity):
|
|
1193
|
+
def wait_for_burn_confirm(ser, dvd_dir, disc_capacity, auto_confirm=False):
|
|
1194
|
+
"""auto_confirm: software/one-click mode has no physical encoder to click
|
|
1195
|
+
here (the confirm-before-committing gesture this gate exists for), and
|
|
1196
|
+
nothing on the web side ever sends CONFIRM/START to it either - without
|
|
1197
|
+
this it blocks forever, looking exactly like a frozen/stuck burn. Every
|
|
1198
|
+
other flow in this app already treats picking a one-click mode as the
|
|
1199
|
+
whole confirmation; this gate does the same for hardware only now."""
|
|
1184
1200
|
data_size = tree_size(dvd_dir)
|
|
1185
1201
|
detected_capacity = disc_capacity_bytes(disc_device())
|
|
1186
1202
|
actual_cap = disc_output_limit_bytes(detected_capacity or disc_capacity)
|
|
@@ -1188,6 +1204,8 @@ def wait_for_burn_confirm(ser, dvd_dir, disc_capacity):
|
|
|
1188
1204
|
data_gb = data_size / 1_000_000_000
|
|
1189
1205
|
line = f"WAITING:{data_gb:.2f}GB / {cap_gb:.1f}GB"
|
|
1190
1206
|
send(ser, line)
|
|
1207
|
+
if auto_confirm:
|
|
1208
|
+
return True
|
|
1191
1209
|
last_ping = time.time()
|
|
1192
1210
|
while True:
|
|
1193
1211
|
if serial_write_failed():
|
package/src/static/app.js
CHANGED
|
@@ -51,11 +51,16 @@
|
|
|
51
51
|
function setProgress(phase, percent, active = true) {
|
|
52
52
|
const panel = $("global-progress");
|
|
53
53
|
const fill = $("progress-fill");
|
|
54
|
-
|
|
54
|
+
// Clamp here, the one place this renders - a browser's own upload
|
|
55
|
+
// "progress" event isn't guaranteed to stop exactly at loaded===total
|
|
56
|
+
// (seen reading past 100%), and this is the single choke point every
|
|
57
|
+
// caller (upload, burn/rip progress, SSE) goes through either way.
|
|
58
|
+
const shown = percent >= 0 ? Math.min(100, Math.max(0, Math.round(percent))) : -1;
|
|
59
|
+
panel.hidden = !active && shown < 0;
|
|
55
60
|
$("progress-phase").textContent = (phase || "READY").toUpperCase();
|
|
56
|
-
$("progress-value").textContent =
|
|
57
|
-
fill.style.width =
|
|
58
|
-
fill.classList.toggle("indeterminate",
|
|
61
|
+
$("progress-value").textContent = shown >= 0 ? `${shown}%` : "...";
|
|
62
|
+
fill.style.width = shown >= 0 ? `${shown}%` : "34%";
|
|
63
|
+
fill.classList.toggle("indeterminate", shown < 0);
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
async function pollStatus() {
|
|
@@ -101,13 +106,198 @@
|
|
|
101
106
|
? "A physical remote is attached — on-screen controls are disabled."
|
|
102
107
|
: "No physical remote detected — control DiscStation from here.";
|
|
103
108
|
$("remote-controls").querySelectorAll("button, input").forEach((el) => { el.disabled = hardware; });
|
|
104
|
-
$("remote-transport").hidden = !progress.playing;
|
|
105
|
-
const ejectBtn = $("remote-eject-btn");
|
|
106
109
|
const open = !!progress.tray_open;
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
rpLastArgs = { progress, hardware, trayOpen: open };
|
|
111
|
+
applyPlaybackPanel(progress, hardware, open);
|
|
109
112
|
}
|
|
110
113
|
|
|
114
|
+
// ---- Playback panel: screen (status + VU bars), volume slider, 3 buttons ----
|
|
115
|
+
// Each bar is a fixed-width column (.rp-bar-col) holding two independently
|
|
116
|
+
// positioned pieces: the animated fill and a peak-hold cap. Both are absolutely
|
|
117
|
+
// positioned against the column's own stable full-height box (not each other),
|
|
118
|
+
// so a percentage bottom means the same thing for both regardless of the fill's
|
|
119
|
+
// current height.
|
|
120
|
+
//
|
|
121
|
+
// The backend only ever sends 16 real levels per frame (fixed wire protocol,
|
|
122
|
+
// shared with the physical OLED's own fixed-size display - not changing that
|
|
123
|
+
// here). To visually fill the screen with more, narrower bars than that, a
|
|
124
|
+
// ResizeObserver rebuilds however many ~10px columns actually fit the container's
|
|
125
|
+
// real width, and renderVuBars maps each one to its source band by nearest
|
|
126
|
+
// neighbor. #remote-panel starts `hidden` and only appears once the first
|
|
127
|
+
// hardware-detection response lands, so the container reads 0 width at page load -
|
|
128
|
+
// the observer (not a one-shot measurement) is what catches the real size once
|
|
129
|
+
// the panel actually becomes visible.
|
|
130
|
+
let rpBarFillEls = [], rpBarPeakEls = [];
|
|
131
|
+
let rpBarTargets = [], rpBarShown = [], rpPeakShown = [];
|
|
132
|
+
let rpBarRafId = null;
|
|
133
|
+
const PEAK_FALL_PER_FRAME = 0.8; // %/frame - slow drift down, classic "hang then fall"
|
|
134
|
+
const RP_BAR_WIDTH = 2, RP_BAR_GAP = 1; // must match .rp-bar-col/.rp-bars CSS
|
|
135
|
+
|
|
136
|
+
function buildRpBars(count) {
|
|
137
|
+
const container = $("rp-bars");
|
|
138
|
+
container.innerHTML = "";
|
|
139
|
+
rpBarFillEls = []; rpBarPeakEls = [];
|
|
140
|
+
for (let i = 0; i < count; i++) {
|
|
141
|
+
const col = document.createElement("span");
|
|
142
|
+
col.className = "rp-bar-col";
|
|
143
|
+
const fill = document.createElement("span");
|
|
144
|
+
fill.className = "rp-bar-fill";
|
|
145
|
+
const peak = document.createElement("span");
|
|
146
|
+
peak.className = "rp-bar-peak";
|
|
147
|
+
col.append(fill, peak);
|
|
148
|
+
container.appendChild(col);
|
|
149
|
+
rpBarFillEls.push(fill);
|
|
150
|
+
rpBarPeakEls.push(peak);
|
|
151
|
+
}
|
|
152
|
+
rpBarTargets = new Array(count).fill(0);
|
|
153
|
+
rpBarShown = new Array(count).fill(0);
|
|
154
|
+
rpPeakShown = new Array(count).fill(0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function initRpBars() {
|
|
158
|
+
buildRpBars(16); // usable default before the panel's real width is known
|
|
159
|
+
new ResizeObserver((entries) => {
|
|
160
|
+
const width = entries[0].contentRect.width;
|
|
161
|
+
if (width <= 0) return;
|
|
162
|
+
const count = Math.max(16, Math.floor((width + RP_BAR_GAP) / (RP_BAR_WIDTH + RP_BAR_GAP)));
|
|
163
|
+
if (count !== rpBarFillEls.length) buildRpBars(count);
|
|
164
|
+
}).observe($("rp-bars"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// VU frames arrive over SSE at a genuinely irregular interval (measured live:
|
|
168
|
+
// clustered around 5/42/85/127ms, not a steady rate - PulseAudio's own delivery
|
|
169
|
+
// fragments don't evenly divide our desired chunk size). A fixed-duration CSS
|
|
170
|
+
// transition can't win against that: too short and the big gaps still jump, too
|
|
171
|
+
// long and it self-interrupts during the tight bursts. Decoupling render timing
|
|
172
|
+
// from arrival timing instead - track a target per bar, ease toward it every real
|
|
173
|
+
// animation frame - is smooth regardless of how the data actually arrives.
|
|
174
|
+
function renderVuBars(levels) {
|
|
175
|
+
if (!levels) return;
|
|
176
|
+
const n = rpBarFillEls.length;
|
|
177
|
+
const lastBand = levels.length - 1;
|
|
178
|
+
for (let i = 0; i < n; i++) {
|
|
179
|
+
// Linear interpolation between the two real bands straddling this bar's
|
|
180
|
+
// position, not nearest-neighbor - copying one real value across a whole
|
|
181
|
+
// run of bars is exactly what made groups of them move as one identical
|
|
182
|
+
// block once there were many more bars than real data points (16).
|
|
183
|
+
const pos = (i * lastBand) / (n - 1); // bar 0 -> band 0, bar n-1 -> last band
|
|
184
|
+
const lo = Math.floor(pos), hi = Math.min(lastBand, lo + 1), frac = pos - lo;
|
|
185
|
+
const src = levels[lo] * (1 - frac) + levels[hi] * frac;
|
|
186
|
+
rpBarTargets[i] = Math.max(0, Math.min(100, (src / 63) * 100));
|
|
187
|
+
}
|
|
188
|
+
if (rpBarRafId === null) rpBarRafId = requestAnimationFrame(tickVuBars);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function tickVuBars() {
|
|
192
|
+
let stillMoving = false;
|
|
193
|
+
rpBarFillEls.forEach((fill, i) => {
|
|
194
|
+
const diff = rpBarTargets[i] - rpBarShown[i];
|
|
195
|
+
if (Math.abs(diff) > 0.5) { rpBarShown[i] += diff * 0.35; stillMoving = true; }
|
|
196
|
+
else { rpBarShown[i] = rpBarTargets[i]; }
|
|
197
|
+
fill.style.height = `${rpBarShown[i]}%`;
|
|
198
|
+
|
|
199
|
+
if (rpBarTargets[i] >= rpPeakShown[i]) { rpPeakShown[i] = rpBarTargets[i]; }
|
|
200
|
+
else { rpPeakShown[i] = Math.max(0, rpPeakShown[i] - PEAK_FALL_PER_FRAME); }
|
|
201
|
+
rpBarPeakEls[i].style.bottom = `${rpPeakShown[i]}%`;
|
|
202
|
+
rpBarPeakEls[i].style.opacity = rpPeakShown[i] > 1 ? "1" : "0";
|
|
203
|
+
if (rpPeakShown[i] > 0) stillMoving = true;
|
|
204
|
+
});
|
|
205
|
+
rpBarRafId = stillMoving ? requestAnimationFrame(tickVuBars) : null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function resetVuBars() {
|
|
209
|
+
rpBarTargets.fill(0);
|
|
210
|
+
rpBarShown.fill(0);
|
|
211
|
+
rpPeakShown.fill(0);
|
|
212
|
+
rpBarFillEls.forEach((fill) => { fill.style.height = "0%"; });
|
|
213
|
+
rpBarPeakEls.forEach((peak) => { peak.style.bottom = "0%"; peak.style.opacity = "0"; });
|
|
214
|
+
if (rpBarRafId !== null) { cancelAnimationFrame(rpBarRafId); rpBarRafId = null; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let rpLastPlaying = false;
|
|
218
|
+
function applyPlaybackPanel(progress, hardware, trayOpen) {
|
|
219
|
+
const wasPlaying = rpLastPlaying;
|
|
220
|
+
rpLastPlaying = !!progress.playing;
|
|
221
|
+
const menuItems = (rpDiscInfo && rpDiscInfo.menu_items) || [];
|
|
222
|
+
|
|
223
|
+
// Mirrors the OLED's own state machine (arduino/DiscStation/DiscStation.ino):
|
|
224
|
+
// playing -> live track status + VU bars (drawPlay); idle with a disc in the
|
|
225
|
+
// drive -> the disc's name/status line + the modes it offers (drawHome); no
|
|
226
|
+
// disc at all (e.g. just ejected) -> plain status text, neither bars nor menu.
|
|
227
|
+
const idleWithDisc = !progress.playing && rpDiscInfo && rpDiscInfo.disc_present && !rpDiscInfo.busy;
|
|
228
|
+
if (idleWithDisc) {
|
|
229
|
+
$("rp-status").textContent = (rpDiscInfo.disc_title || discLineText(rpDiscInfo)).toUpperCase();
|
|
230
|
+
} else {
|
|
231
|
+
// CSS handles the visual cutoff (ellipsis) - no hard slice here so it
|
|
232
|
+
// never chops a word off abruptly mid-string.
|
|
233
|
+
$("rp-status").textContent = (progress.status || "READY").toUpperCase();
|
|
234
|
+
}
|
|
235
|
+
// Bars belong on screen only while something is actually playing - not just
|
|
236
|
+
// "not idle-with-a-disc" (that left them visibly stuck at their last frame
|
|
237
|
+
// once playback stopped with no disc left to show a menu for instead).
|
|
238
|
+
$("rp-bars").hidden = !progress.playing;
|
|
239
|
+
if (wasPlaying && !progress.playing) resetVuBars(); // clear the last frame, don't leave it stuck
|
|
240
|
+
$("rp-menu").hidden = !idleWithDisc;
|
|
241
|
+
if (idleWithDisc) {
|
|
242
|
+
$("rp-menu").innerHTML = "";
|
|
243
|
+
menuItems.forEach((mode) => {
|
|
244
|
+
const line = document.createElement("div");
|
|
245
|
+
line.textContent = mode;
|
|
246
|
+
$("rp-menu").appendChild(line);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const ejectBtn = $("remote-controls").querySelector('[data-role="eject"]');
|
|
251
|
+
if (ejectBtn) { ejectBtn.dataset.cmd = trayOpen ? "CONFIRM" : "EJECT"; ejectBtn.disabled = hardware; }
|
|
252
|
+
|
|
253
|
+
// HOME/BACK: PLAY_STOP while playing, CANCEL otherwise - matches the
|
|
254
|
+
// firmware's own handleHomeButton (uiState == UI_PLAY ? "PLAY_STOP" : "CANCEL").
|
|
255
|
+
const homeBtn = $("remote-controls").querySelector('[data-role="home"]');
|
|
256
|
+
if (homeBtn) { homeBtn.dataset.cmd = progress.playing ? "PLAY_STOP" : "CANCEL"; homeBtn.disabled = hardware; }
|
|
257
|
+
|
|
258
|
+
// PLAY/PAUSE: toggle pause while playing; jump into PLAY from idle only when
|
|
259
|
+
// the disc offers it (matches handlePlayPauseButton) - disabled otherwise,
|
|
260
|
+
// e.g. correctly dead during a RIP.
|
|
261
|
+
const ppBtn = $("remote-controls").querySelector('[data-role="playpause"]');
|
|
262
|
+
if (ppBtn) {
|
|
263
|
+
const canPlay = menuItems.includes("PLAY");
|
|
264
|
+
if (progress.playing) { ppBtn.dataset.cmd = "PLAY_BUTTON"; ppBtn.disabled = hardware; }
|
|
265
|
+
else if (canPlay) { ppBtn.dataset.cmd = "SELECT:PLAY"; ppBtn.disabled = hardware; }
|
|
266
|
+
else { ppBtn.disabled = true; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Track skip only makes sense mid-song, for an audio CD specifically (not a
|
|
270
|
+
// DVD-Video/etc. play) - /progress doesn't carry disc kind, so fall back to
|
|
271
|
+
// the cached /disc-info, which keeps reporting it while the drive is held
|
|
272
|
+
// busy for the whole play session (see _serve_disc_info's _operation_active
|
|
273
|
+
// branch).
|
|
274
|
+
const trackRow = $("rp-trackrow");
|
|
275
|
+
if (trackRow) {
|
|
276
|
+
const isAudioCd = !!(rpDiscInfo && rpDiscInfo.kind === "audio_cd");
|
|
277
|
+
trackRow.hidden = !(progress.playing && isAudioCd);
|
|
278
|
+
trackRow.querySelectorAll(".rp-btn").forEach((b) => { b.disabled = hardware; });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let rpDiscInfo = null; // latest /disc-info response - drives the idle HOME-style screen
|
|
283
|
+
function setPlaybackDiscInfo(info) {
|
|
284
|
+
rpDiscInfo = info || null;
|
|
285
|
+
if (rpLastArgs) applyPlaybackPanel(rpLastArgs.progress, rpLastArgs.hardware, rpLastArgs.trayOpen);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
let rpLastArgs = null;
|
|
289
|
+
let volumeTimer;
|
|
290
|
+
$("remote-playback").addEventListener("click", (event) => {
|
|
291
|
+
const btn = event.target.closest("[data-role]");
|
|
292
|
+
if (btn && !btn.disabled) sendRemoteCmd(btn.dataset.cmd);
|
|
293
|
+
});
|
|
294
|
+
$("rp-volume").addEventListener("input", (event) => {
|
|
295
|
+
clearTimeout(volumeTimer);
|
|
296
|
+
const value = event.target.value;
|
|
297
|
+
volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
|
|
298
|
+
});
|
|
299
|
+
initRpBars();
|
|
300
|
+
|
|
111
301
|
// Firmware row: outside the remote panel on purpose - a hardware remote hides
|
|
112
302
|
// that panel, and hardware is exactly when there is a remote to update.
|
|
113
303
|
function applyFirmwareState(progress) {
|
|
@@ -134,7 +324,19 @@
|
|
|
134
324
|
} catch (_) { /* SSE/poll shows the real state */ }
|
|
135
325
|
});
|
|
136
326
|
|
|
137
|
-
|
|
327
|
+
let lastCmdKey = "", lastCmdAt = 0;
|
|
328
|
+
async function sendRemoteCmd(cmd, { allowRepeat = false } = {}) {
|
|
329
|
+
// Guard against a duplicate dispatch of the identical command landing
|
|
330
|
+
// within the same instant (e.g. a click/pointer pair both bubbling to
|
|
331
|
+
// the same handler) - a real user's next distinct press is unaffected,
|
|
332
|
+
// this only catches an exact repeat inside a tiny window. The knob's
|
|
333
|
+
// encoder ticks pass allowRepeat: true - a fast spin legitimately fires
|
|
334
|
+
// the same command (e.g. REW:BIG) several times in a row on purpose,
|
|
335
|
+
// unlike a duplicated button click.
|
|
336
|
+
const now = Date.now();
|
|
337
|
+
if (!allowRepeat && cmd === lastCmdKey && now - lastCmdAt < 250) return;
|
|
338
|
+
lastCmdKey = cmd;
|
|
339
|
+
lastCmdAt = now;
|
|
138
340
|
try {
|
|
139
341
|
await fetch("/remote/button", {
|
|
140
342
|
method: "POST",
|
|
@@ -154,22 +356,11 @@
|
|
|
154
356
|
const button = event.target.closest("[data-cmd]");
|
|
155
357
|
if (!button || button.disabled) return;
|
|
156
358
|
const cmd = button.dataset.cmd;
|
|
359
|
+
// One-click burn: the server queues START right behind SELECT:BURN* in the
|
|
360
|
+
// same request now (was two separate POSTs from here, which raced against
|
|
361
|
+
// the server clearing stale input the instant it read SELECT and could
|
|
362
|
+
// silently drop the START - see _handle_remote_button in discstation.py).
|
|
157
363
|
await sendRemoteCmd(cmd);
|
|
158
|
-
// On real hardware START is a long-press of the encoder on the burn-ready
|
|
159
|
-
// review screen, not its own button - a one-click "BURN AUDIO"/"BURN
|
|
160
|
-
// DATA"/"BURN" here is the whole point of the web remote, so chase the
|
|
161
|
-
// mode select straight through to starting the burn instead of leaving
|
|
162
|
-
// the user stuck with nothing left to press. (The backend queues this
|
|
163
|
-
// harmlessly if files/URL aren't in yet - it's only consumed once the
|
|
164
|
-
// burn's actually at its own "waiting for start" step, and gets flushed
|
|
165
|
-
// if a different mode gets selected first so it can't fire the wrong burn.)
|
|
166
|
-
if (cmd.startsWith("SELECT:BURN")) await sendRemoteCmd("START");
|
|
167
|
-
});
|
|
168
|
-
let volumeTimer;
|
|
169
|
-
$("remote-volume").addEventListener("input", (event) => {
|
|
170
|
-
clearTimeout(volumeTimer);
|
|
171
|
-
const value = event.target.value;
|
|
172
|
-
volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
|
|
173
364
|
});
|
|
174
365
|
|
|
175
366
|
// Mode-select buttons this server-computed list governs - CANCEL/EJECT are
|
|
@@ -191,6 +382,7 @@
|
|
|
191
382
|
const info = await response.json();
|
|
192
383
|
renderDiscStatus(info);
|
|
193
384
|
applyMenuItems(info.menu_items);
|
|
385
|
+
setPlaybackDiscInfo(info);
|
|
194
386
|
if (info.busy) return; // burn/rip in progress — keep current
|
|
195
387
|
state.discBytes = Number(info.capacity_bytes || 0);
|
|
196
388
|
state.discType = info.type || "none";
|
|
@@ -202,16 +394,19 @@
|
|
|
202
394
|
}
|
|
203
395
|
}
|
|
204
396
|
|
|
205
|
-
function
|
|
206
|
-
|
|
207
|
-
if (
|
|
208
|
-
if (!info)
|
|
209
|
-
if (info.busy) { el.textContent = "DISC: BUSY"; return; }
|
|
210
|
-
if (!info.disc_present) { el.textContent = "DISC: NONE"; return; }
|
|
397
|
+
function discLineText(info) {
|
|
398
|
+
if (!info) return "DISC: UNKNOWN";
|
|
399
|
+
if (info.busy) return "DISC: BUSY";
|
|
400
|
+
if (!info.disc_present) return "DISC: NONE";
|
|
211
401
|
const kind = (info.type || info.kind || "unknown").toUpperCase();
|
|
212
402
|
const label = info.label ? ` "${info.label}"` : "";
|
|
213
403
|
const size = info.capacity_gb ? ` // ${info.capacity_gb}GB` : "";
|
|
214
|
-
|
|
404
|
+
return `DISC: ${kind}${label}${size}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function renderDiscStatus(info) {
|
|
408
|
+
const el = $("remote-disc-status");
|
|
409
|
+
if (el) el.textContent = discLineText(info);
|
|
215
410
|
}
|
|
216
411
|
|
|
217
412
|
function renderSelection() {
|
|
@@ -393,6 +588,7 @@
|
|
|
393
588
|
let d;
|
|
394
589
|
try { d = JSON.parse(ev.data); } catch (_) { return; }
|
|
395
590
|
if (d.type === "disc-changed") { loadDiscInfo(); return; }
|
|
591
|
+
if (d.type === "vu") { renderVuBars(d.levels); return; }
|
|
396
592
|
setConnection(true);
|
|
397
593
|
setLiveStatus(d.status);
|
|
398
594
|
setProgress(d.status, Number(d.progress), d.active);
|
package/src/static/index.html
CHANGED
|
@@ -56,19 +56,25 @@
|
|
|
56
56
|
<button class="outline-button" type="button" data-cmd="SELECT:BURN DATA">BURN DATA</button>
|
|
57
57
|
<button class="outline-button" type="button" data-cmd="SELECT:BURN AUDIO">BURN AUDIO</button>
|
|
58
58
|
<button class="outline-button" type="button" data-cmd="SELECT:RIP">RIP</button>
|
|
59
|
-
<button class="outline-button" type="button" data-cmd="SELECT:PLAY">PLAY</button>
|
|
60
|
-
<button class="outline-button" type="button" data-cmd="CANCEL">CANCEL / HOME</button>
|
|
61
|
-
<button class="outline-button" type="button" id="remote-eject-btn" data-cmd="EJECT">EJECT</button>
|
|
62
59
|
</div>
|
|
63
|
-
<div id="remote-
|
|
64
|
-
<div class="
|
|
65
|
-
<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
<div id="remote-playback" class="remote-playback">
|
|
61
|
+
<div class="rp-main">
|
|
62
|
+
<div id="rp-trackrow" class="rp-trackrow" hidden>
|
|
63
|
+
<button class="rp-btn rp-trackbtn" type="button" data-role="prev" data-cmd="REW:BIG" aria-label="Previous track"><span class="rp-glyph">⏮</span></button>
|
|
64
|
+
<button class="rp-btn rp-trackbtn" type="button" data-role="next" data-cmd="FF:BIG" aria-label="Next track"><span class="rp-glyph">⏭</span></button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="rp-screen">
|
|
67
|
+
<div id="rp-status" class="rp-status">READY</div>
|
|
68
|
+
<div id="rp-bars" class="rp-bars"></div>
|
|
69
|
+
<div id="rp-menu" class="rp-menu" hidden></div>
|
|
70
|
+
</div>
|
|
71
|
+
<input id="rp-volume" type="range" min="0" max="100" value="70" aria-label="Volume">
|
|
72
|
+
</div>
|
|
73
|
+
<div class="rp-buttons">
|
|
74
|
+
<button class="rp-btn" type="button" data-role="eject" aria-label="Eject"><span class="rp-glyph">⏏</span></button>
|
|
75
|
+
<button class="rp-btn" type="button" data-role="home" aria-label="Home / Back"><span class="rp-dot"></span></button>
|
|
76
|
+
<button class="rp-btn" type="button" data-role="playpause" aria-label="Play / Pause"><span class="rp-glyph">⏯</span></button>
|
|
69
77
|
</div>
|
|
70
|
-
<label class="field-label" for="remote-volume">VOLUME</label>
|
|
71
|
-
<input id="remote-volume" type="range" min="0" max="100" value="70">
|
|
72
78
|
</div>
|
|
73
79
|
</div>
|
|
74
80
|
</section>
|
|
@@ -144,7 +150,7 @@
|
|
|
144
150
|
</footer>
|
|
145
151
|
</div>
|
|
146
152
|
<div id="install-slot"></div>
|
|
147
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
148
|
-
<script src="/static/app.js?v=
|
|
153
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=46").catch(() => {});</script>
|
|
154
|
+
<script src="/static/app.js?v=46" defer></script>
|
|
149
155
|
</body>
|
|
150
156
|
</html>
|
package/src/static/style.css
CHANGED
|
@@ -304,8 +304,110 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
|
|
|
304
304
|
}
|
|
305
305
|
.remote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 16px; }
|
|
306
306
|
#remote-controls button:disabled, #remote-controls input:disabled { cursor: not-allowed; opacity: 0.4; }
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
/* Playback control panel: a screen + volume slider beside a column of 3 icon
|
|
308
|
+
buttons (EJECT / HOME / PLAY-PAUSE) - plain HTML/CSS, themed with the page's
|
|
309
|
+
own custom properties like everything else here. Always visible (same as
|
|
310
|
+
CANCEL/EJECT above it), not gated behind a view toggle. */
|
|
311
|
+
.remote-playback {
|
|
312
|
+
margin-top: 20px;
|
|
313
|
+
padding-top: 18px;
|
|
314
|
+
border-top: 1px solid var(--soft-line);
|
|
315
|
+
display: flex;
|
|
316
|
+
gap: 12px;
|
|
317
|
+
}
|
|
318
|
+
.rp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
|
319
|
+
.rp-screen {
|
|
320
|
+
background: #0a0a0a;
|
|
321
|
+
border: 1px solid var(--ink);
|
|
322
|
+
border-radius: 3px;
|
|
323
|
+
box-shadow: inset 0 0 0 3px #1a1a1a, inset 0 2px 6px rgba(0, 0, 0, 0.6);
|
|
324
|
+
padding: 10px 12px 8px;
|
|
325
|
+
min-height: 60px;
|
|
326
|
+
flex: 1; /* fill .rp-main's column so it lines up with the 3-button column beside it */
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-direction: column;
|
|
329
|
+
gap: 8px;
|
|
330
|
+
}
|
|
331
|
+
.rp-status {
|
|
332
|
+
color: #f2f2f2;
|
|
333
|
+
font-family: var(--mono);
|
|
334
|
+
font-size: 11px;
|
|
335
|
+
font-weight: 700;
|
|
336
|
+
letter-spacing: 0.05em;
|
|
337
|
+
white-space: nowrap;
|
|
338
|
+
overflow: hidden;
|
|
339
|
+
text-overflow: ellipsis;
|
|
340
|
+
}
|
|
341
|
+
.rp-bars {
|
|
342
|
+
display: flex;
|
|
343
|
+
align-items: stretch; /* each .rp-bar-col gets the full height as a stable box for its two absolutely-positioned children */
|
|
344
|
+
justify-content: space-between; /* bars stay a fixed width but spread across the full screen width */
|
|
345
|
+
gap: 1px;
|
|
346
|
+
flex: 1; /* fill the screen down to its bottom edge, not just a fixed strip under the text */
|
|
347
|
+
border-top: 1px solid #262626;
|
|
348
|
+
padding-top: 4px;
|
|
349
|
+
}
|
|
350
|
+
.rp-bar-col { position: relative; flex: 0 0 2px; width: 2px; }
|
|
351
|
+
.rp-bar-fill {
|
|
352
|
+
position: absolute;
|
|
353
|
+
left: 0; right: 0; bottom: 0;
|
|
354
|
+
height: 0;
|
|
355
|
+
min-height: 1px;
|
|
356
|
+
background: var(--accent);
|
|
357
|
+
box-shadow: 0 0 6px var(--accent);
|
|
358
|
+
}
|
|
359
|
+
.rp-bar-peak {
|
|
360
|
+
position: absolute;
|
|
361
|
+
left: 0; right: 0; bottom: 0;
|
|
362
|
+
height: 2px;
|
|
363
|
+
background: var(--accent);
|
|
364
|
+
box-shadow: 0 0 5px var(--accent);
|
|
365
|
+
opacity: 0;
|
|
366
|
+
transition: opacity 0.15s linear;
|
|
367
|
+
}
|
|
368
|
+
/* Idle-with-disc: the OLED's own HOME screen shows the modes this disc offers below
|
|
369
|
+
its name/status line - this is that list, filling the same slot bars use while
|
|
370
|
+
playing (see applyPlaybackPanel in app.js). */
|
|
371
|
+
.rp-menu {
|
|
372
|
+
flex: 1;
|
|
373
|
+
display: flex;
|
|
374
|
+
flex-direction: column;
|
|
375
|
+
justify-content: center;
|
|
376
|
+
gap: 3px;
|
|
377
|
+
border-top: 1px solid #262626;
|
|
378
|
+
padding-top: 4px;
|
|
379
|
+
color: var(--accent);
|
|
380
|
+
font-family: var(--mono);
|
|
381
|
+
font-size: 10px;
|
|
382
|
+
font-weight: 700;
|
|
383
|
+
letter-spacing: 0.05em;
|
|
384
|
+
}
|
|
385
|
+
/* Both rows set their own `display` above, which otherwise beats the browser's
|
|
386
|
+
default [hidden] { display: none } (author styles always win over the UA
|
|
387
|
+
stylesheet) - the `hidden` attribute was being set correctly in JS but doing
|
|
388
|
+
nothing visually without this. */
|
|
389
|
+
.rp-bars[hidden], .rp-menu[hidden] { display: none; }
|
|
390
|
+
.rp-main input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|
391
|
+
.rp-buttons { display: flex; flex-direction: column; gap: 8px; }
|
|
392
|
+
.rp-btn {
|
|
393
|
+
width: 52px;
|
|
394
|
+
height: 52px;
|
|
395
|
+
border: 1px solid var(--ink);
|
|
396
|
+
background: var(--surface);
|
|
397
|
+
cursor: pointer;
|
|
398
|
+
display: flex;
|
|
399
|
+
align-items: center;
|
|
400
|
+
justify-content: center;
|
|
401
|
+
transition: background 0.15s ease;
|
|
402
|
+
}
|
|
403
|
+
.rp-btn:hover { background: var(--paper-deep); }
|
|
404
|
+
.rp-btn:active { background: var(--accent); }
|
|
405
|
+
.rp-btn:disabled { cursor: not-allowed; opacity: 0.4; }
|
|
406
|
+
.rp-glyph { font-size: 22px; color: var(--ink); }
|
|
407
|
+
.rp-dot { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--ink); }
|
|
408
|
+
.rp-trackrow { display: flex; gap: 8px; }
|
|
409
|
+
.rp-trackrow[hidden] { display: none; }
|
|
410
|
+
.rp-trackbtn { flex: 1; width: auto; height: 32px; }
|
|
309
411
|
|
|
310
412
|
.footer-stamp {
|
|
311
413
|
display: flex;
|