discstation 0.1.43 → 0.3.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 +16 -7
- package/arduino/firmware/discstation.bin +0 -0
- package/arduino/firmware/firmware.json +1 -1
- package/package.json +1 -1
- package/src/discstation.py +122 -25
- package/src/discstation_burn.py +25 -7
- package/src/static/app.js +247 -18
- package/src/static/index.html +20 -13
- package/src/static/style.css +130 -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 OLED's spinning-disc screensaver appears on its home/standby screens or after playback has been paused for 15 s, while the web/app remote shows the same spinning disc immediately in place of the visualizer any time there's no real audio data to show (paused, or a host platform that can't capture audio at all) |
|
|
75
75
|
| **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
|
|
76
76
|
|
|
77
77
|
## Project Structure
|
|
@@ -172,9 +172,14 @@ 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
|
-
|
|
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) - or a spinning-disc
|
|
179
|
+
animation in its place whenever there's no real audio data to visualize
|
|
180
|
+
(paused, or a host platform with no audio capture). EJECT/CLOSE toggle,
|
|
181
|
+
volume, and full playback transport including PREV/NEXT track for audio
|
|
182
|
+
CDs. Greys out and auto-collapses the instant a physical remote is
|
|
178
183
|
detected, so the two never fight for control — including mid-burn/rip/play:
|
|
179
184
|
plugging the remote in hands it control without interrupting whatever's
|
|
180
185
|
running, and its screen picks up the current status instead of starting
|
|
@@ -187,9 +192,13 @@ The `mobile/` Expo app (enter the host's LAN address in its settings screen)
|
|
|
187
192
|
has the same on-screen remote as the web UI, rendered inline in the main
|
|
188
193
|
screen rather than a popup — same "REMOTE" tag toggle, opens automatically
|
|
189
194
|
with no ESP32 attached, mode buttons filtered to what the disc actually
|
|
190
|
-
supports, and picking one starts it directly.
|
|
191
|
-
|
|
192
|
-
|
|
195
|
+
supports, and picking one starts it directly. Its screen has the same
|
|
196
|
+
idle title/offered-modes view, live spectrum visualizer (or spinning-disc
|
|
197
|
+
fallback when there's no real audio data to show) and PREV/NEXT track
|
|
198
|
+
controls for audio CDs as the web UI's playback screen - subscribed to the
|
|
199
|
+
same `/events` SSE stream. Same auto-collapse behavior when a physical ESP32
|
|
200
|
+
shows up — mid-operation and without interruption, same as the web UI — and
|
|
201
|
+
the same underlying `/remote/button` protocol.
|
|
193
202
|
|
|
194
203
|
## Disc Support
|
|
195
204
|
|
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
1
|
+
{"version":"0.3.0","size":1260592,"md5":"e0959615d438880783baf38f8943ae3f"}
|
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,18 +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)
|
|
533
|
-
# One-click burn
|
|
534
|
-
#
|
|
535
|
-
#
|
|
536
|
-
#
|
|
537
|
-
#
|
|
538
|
-
#
|
|
539
|
-
|
|
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():
|
|
540
547
|
_active_ser.virtual.push_line("START")
|
|
541
548
|
self._respond(200, 'OK')
|
|
542
549
|
else:
|
|
543
550
|
self._respond(409, 'A hardware remote is attached')
|
|
544
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
|
+
|
|
545
561
|
def _serve_firmware(self):
|
|
546
562
|
image, _ = _firmware_image()
|
|
547
563
|
if not image:
|
|
@@ -584,10 +600,35 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
584
600
|
# the one already running (CANCEL/EJECT stay available regardless).
|
|
585
601
|
self._respond(200, json.dumps({**_last_disc_info, "busy": True, "menu_items": [], "appliance": _appliance_mode}), "application/json")
|
|
586
602
|
return
|
|
603
|
+
device = discstation_burn.disc_device()
|
|
604
|
+
if not _detect_lock.acquire(timeout=1.5):
|
|
605
|
+
# Something else (almost always the background poller) is already
|
|
606
|
+
# deep into a classify for this disc - waiting on detect_disc()'s
|
|
607
|
+
# own (unbounded, blocking) internal acquire would just queue
|
|
608
|
+
# behind it for however long that takes (seen ~90s on a disc whose
|
|
609
|
+
# blkid/wodim-toc probes both time out) - an earlier version of
|
|
610
|
+
# this fix acquired-then-released this same lock before calling
|
|
611
|
+
# detect_disc() as a "quick test," which didn't actually help: by
|
|
612
|
+
# the time detect_disc() re-acquired the lock for real, another
|
|
613
|
+
# thread could (and, live-tested, did) grab it first and leave
|
|
614
|
+
# this request blocked on that *second*, timeout-less acquire
|
|
615
|
+
# anyway. Holding the lock we already have for the whole classify
|
|
616
|
+
# - calling _detect_disc_locked() directly instead of going back
|
|
617
|
+
# through detect_disc() - is what actually bounds the wait.
|
|
618
|
+
stale = {**_last_disc_info}
|
|
619
|
+
stale.setdefault("type", "reading")
|
|
620
|
+
self._respond(200, json.dumps({**stale, "appliance": _appliance_mode}), "application/json")
|
|
621
|
+
return
|
|
587
622
|
info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
588
623
|
try:
|
|
589
|
-
|
|
590
|
-
|
|
624
|
+
try:
|
|
625
|
+
cached = _detect_cache.get(device)
|
|
626
|
+
if cached and time.monotonic() - cached[0] < DISC_DETECT_CACHE_TTL:
|
|
627
|
+
di = cached[1]
|
|
628
|
+
else:
|
|
629
|
+
di = _detect_disc_locked(device, settle=False, budget=15)
|
|
630
|
+
finally:
|
|
631
|
+
_detect_lock.release()
|
|
591
632
|
info["disc_present"] = di.present
|
|
592
633
|
info["capacity_bytes"] = di.capacity_bytes
|
|
593
634
|
info["capacity_gb"] = round(di.capacity_bytes / 1e9, 2)
|
|
@@ -599,6 +640,10 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
599
640
|
info["type"] = di.web_type
|
|
600
641
|
info["kind"] = di.kind
|
|
601
642
|
info["label"] = di.label
|
|
643
|
+
# Same lookup the OLED's DISC_NAME: already uses (TOC-fingerprint match
|
|
644
|
+
# against burn history for an audio CD, volume label for a data disc/DVD) -
|
|
645
|
+
# richer than "label" above, which is empty for most audio CDs.
|
|
646
|
+
info["disc_title"] = disc_title(device) if di.present and not di.transient else ""
|
|
602
647
|
info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
|
|
603
648
|
except Exception as e:
|
|
604
649
|
print(f"Disc info error: {e}")
|
|
@@ -653,7 +698,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
653
698
|
def _serve_sw(self):
|
|
654
699
|
sw = '''self.addEventListener('install', e => {
|
|
655
700
|
self.skipWaiting();
|
|
656
|
-
caches.open('discstation-
|
|
701
|
+
caches.open('discstation-v47').then(c => c.addAll(['/','/static/style.css?v=47','/static/app.js?v=47']));
|
|
657
702
|
});
|
|
658
703
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
659
704
|
self.addEventListener('fetch', e => {
|
|
@@ -662,7 +707,7 @@ self.addEventListener('fetch', e => {
|
|
|
662
707
|
if (path === '/' || path.startsWith('/static/')) {
|
|
663
708
|
e.respondWith(fetch(e.request).then(r => {
|
|
664
709
|
const copy = r.clone();
|
|
665
|
-
caches.open('discstation-
|
|
710
|
+
caches.open('discstation-v47').then(c => c.put(e.request, copy));
|
|
666
711
|
return r;
|
|
667
712
|
}).catch(() => caches.match(e.request)));
|
|
668
713
|
} else {
|
|
@@ -1077,7 +1122,7 @@ def _set_web_progress(phase, percent=-1):
|
|
|
1077
1122
|
|
|
1078
1123
|
|
|
1079
1124
|
def _record_web_status(msg):
|
|
1080
|
-
global _web_status, _web_progress, _web_progress_active, _web_playing
|
|
1125
|
+
global _web_status, _web_progress, _web_progress_active, _web_playing, _web_phase
|
|
1081
1126
|
_remember(msg)
|
|
1082
1127
|
if msg.startswith("DISC:"):
|
|
1083
1128
|
_sse_publish({"type": "disc-changed"})
|
|
@@ -1088,12 +1133,20 @@ def _record_web_status(msg):
|
|
|
1088
1133
|
_web_progress_active = True
|
|
1089
1134
|
elif msg.startswith("PLAY_STATUS:") or msg.startswith("PLAY:"):
|
|
1090
1135
|
_web_status = msg.split(":", 1)[1].strip() or "PLAYING"
|
|
1136
|
+
elif msg.startswith("WAITING:"):
|
|
1137
|
+
# The OLED's pre-burn confirm screen ("does this fit? confirm to
|
|
1138
|
+
# go") - was silently dropped before (not in this elif chain at
|
|
1139
|
+
# all), which is exactly why a burn stuck here looked frozen on
|
|
1140
|
+
# the web instead of showing what it's actually waiting on.
|
|
1141
|
+
_web_status = "Waiting to confirm: " + (msg[8:].strip() or "...")
|
|
1142
|
+
_web_progress_active = True
|
|
1091
1143
|
elif msg.startswith("STATUS:"):
|
|
1092
1144
|
_web_status = msg[7:].strip() or "READY"
|
|
1145
|
+
_web_phase = _web_status.rstrip(".")
|
|
1093
1146
|
_web_progress_active = True
|
|
1094
1147
|
elif msg.startswith("PROGRESS:"):
|
|
1095
1148
|
value = msg[9:].strip()
|
|
1096
|
-
_web_status = f"{
|
|
1149
|
+
_web_status = f"{_web_phase} {value}"
|
|
1097
1150
|
match = re.search(r"(\d+(?:\.\d+)?)", value)
|
|
1098
1151
|
if match:
|
|
1099
1152
|
_web_progress = min(100, max(0, int(float(match.group(1)))))
|
|
@@ -1103,14 +1156,17 @@ def _record_web_status(msg):
|
|
|
1103
1156
|
_web_progress = 100
|
|
1104
1157
|
_web_progress_active = False
|
|
1105
1158
|
_web_playing = False
|
|
1159
|
+
_web_phase = "READY"
|
|
1106
1160
|
elif msg.startswith("ERROR:"):
|
|
1107
1161
|
_web_status = msg[6:].strip() or "ERROR"
|
|
1108
1162
|
_web_progress_active = False
|
|
1109
1163
|
_web_playing = False
|
|
1164
|
+
_web_phase = "READY"
|
|
1110
1165
|
elif msg.startswith("CANCELLED:"):
|
|
1111
1166
|
_web_status = msg[10:].strip() or "CANCELLED"
|
|
1112
1167
|
_web_progress_active = False
|
|
1113
1168
|
_web_playing = False
|
|
1169
|
+
_web_phase = "READY"
|
|
1114
1170
|
elif msg.startswith(("STANDBY:", "HOME:")):
|
|
1115
1171
|
# idle again (tray open, insert disc, back to the menu) — clear any
|
|
1116
1172
|
# lingering "Ejecting..." / progress state on the web UI.
|
|
@@ -1119,6 +1175,7 @@ def _record_web_status(msg):
|
|
|
1119
1175
|
_web_progress = -1
|
|
1120
1176
|
_web_progress_active = False
|
|
1121
1177
|
_web_playing = False
|
|
1178
|
+
_web_phase = "READY"
|
|
1122
1179
|
else:
|
|
1123
1180
|
return
|
|
1124
1181
|
_sse_publish(_status_snapshot())
|
|
@@ -1504,14 +1561,17 @@ def _wait_for_start(ser, starting=None, mode=None):
|
|
|
1504
1561
|
|
|
1505
1562
|
def run_probe(cmd, timeout=8, name=None):
|
|
1506
1563
|
try:
|
|
1507
|
-
return
|
|
1508
|
-
cmd,
|
|
1509
|
-
|
|
1510
|
-
text=True,
|
|
1511
|
-
timeout=timeout,
|
|
1564
|
+
return _run_hard_timeout(
|
|
1565
|
+
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
|
|
1566
|
+
timeout=timeout + 5,
|
|
1512
1567
|
)
|
|
1513
1568
|
except subprocess.TimeoutExpired as e:
|
|
1514
1569
|
return subprocess.CompletedProcess(cmd, 124, ensure_text(e.stdout), ensure_text(e.stderr))
|
|
1570
|
+
except RuntimeError:
|
|
1571
|
+
# _run_hard_timeout gave up on a child stuck in D-state (uninterruptible
|
|
1572
|
+
# kernel I/O sleep) rather than a clean subprocess-level timeout - same
|
|
1573
|
+
# "this probe didn't answer" outcome from every caller's point of view.
|
|
1574
|
+
return subprocess.CompletedProcess(cmd, 124, "", "")
|
|
1515
1575
|
except (FileNotFoundError, OSError) as e:
|
|
1516
1576
|
return subprocess.CompletedProcess(cmd, 127, "", str(e))
|
|
1517
1577
|
|
|
@@ -1600,6 +1660,9 @@ def udev_cdrom_properties(device, refresh=False):
|
|
|
1600
1660
|
return properties
|
|
1601
1661
|
|
|
1602
1662
|
|
|
1663
|
+
discstation_burn.udev_cdrom_properties = udev_cdrom_properties
|
|
1664
|
+
|
|
1665
|
+
|
|
1603
1666
|
_tray_open = False
|
|
1604
1667
|
_tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
|
|
1605
1668
|
|
|
@@ -2198,6 +2261,32 @@ def menu_items_for_disc(device):
|
|
|
2198
2261
|
return items
|
|
2199
2262
|
|
|
2200
2263
|
|
|
2264
|
+
def _run_hard_timeout(fn, timeout):
|
|
2265
|
+
"""Run fn() on a throwaway daemon thread and bound the wait on the THREAD,
|
|
2266
|
+
not on whatever fn() calls internally. subprocess.run(timeout=N) can't
|
|
2267
|
+
rescue a caller from a child stuck in D-state (uninterruptible kernel I/O
|
|
2268
|
+
sleep - e.g. a drive that can't read a bad/dirty disc): SIGKILL doesn't
|
|
2269
|
+
interrupt D-state, so the library's own kill-then-reap after its own
|
|
2270
|
+
timeout hangs right along with it. This is the only way to guarantee the
|
|
2271
|
+
caller gets control back - the thread (and whatever it's still stuck in)
|
|
2272
|
+
is abandoned/leaked, but nothing ever waits on it again, so any lock the
|
|
2273
|
+
caller holds gets released either way."""
|
|
2274
|
+
result = {}
|
|
2275
|
+
def _target():
|
|
2276
|
+
try:
|
|
2277
|
+
result["value"] = fn()
|
|
2278
|
+
except Exception as e:
|
|
2279
|
+
result["error"] = e
|
|
2280
|
+
t = threading.Thread(target=_target, daemon=True)
|
|
2281
|
+
t.start()
|
|
2282
|
+
t.join(timeout)
|
|
2283
|
+
if t.is_alive():
|
|
2284
|
+
raise RuntimeError("drive not responding (timed out)")
|
|
2285
|
+
if "error" in result:
|
|
2286
|
+
raise result["error"]
|
|
2287
|
+
return result["value"]
|
|
2288
|
+
|
|
2289
|
+
|
|
2201
2290
|
class mounted_disc:
|
|
2202
2291
|
def __init__(self, device):
|
|
2203
2292
|
self.device = device
|
|
@@ -2228,7 +2317,14 @@ class mounted_disc:
|
|
|
2228
2317
|
else:
|
|
2229
2318
|
self.tmp.cleanup()
|
|
2230
2319
|
raise RuntimeError("Disc mounting backend is not configured for this operating system")
|
|
2231
|
-
|
|
2320
|
+
try:
|
|
2321
|
+
result = _run_hard_timeout(
|
|
2322
|
+
lambda: subprocess.run(command, capture_output=True, text=True, timeout=30),
|
|
2323
|
+
timeout=35,
|
|
2324
|
+
)
|
|
2325
|
+
except Exception:
|
|
2326
|
+
self.tmp.cleanup()
|
|
2327
|
+
raise
|
|
2232
2328
|
if result.returncode != 0:
|
|
2233
2329
|
self.tmp.cleanup()
|
|
2234
2330
|
raise RuntimeError((result.stderr or result.stdout or "Could not mount disc").strip())
|
|
@@ -2980,7 +3076,7 @@ def burn_flow(ser):
|
|
|
2980
3076
|
)
|
|
2981
3077
|
|
|
2982
3078
|
if plan["burn"]:
|
|
2983
|
-
discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes)
|
|
3079
|
+
discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes, auto_confirm=ser.hw is None)
|
|
2984
3080
|
discstation_burn.burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
2985
3081
|
safe_send(ser, "DONE:Disc complete!")
|
|
2986
3082
|
print("Burn complete.")
|
|
@@ -3249,8 +3345,8 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3249
3345
|
if proc.poll() is not None:
|
|
3250
3346
|
break
|
|
3251
3347
|
continue
|
|
3252
|
-
if pause_event.is_set() or ser.hw is None
|
|
3253
|
-
continue
|
|
3348
|
+
if pause_event.is_set() or (ser.hw is None and not _sse_subs):
|
|
3349
|
+
continue # nobody to draw bars for - no OLED, no browser watching either
|
|
3254
3350
|
samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
|
|
3255
3351
|
spectrum = _np.abs(_np.fft.rfft(samples * window))
|
|
3256
3352
|
mags = []
|
|
@@ -3275,6 +3371,8 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3275
3371
|
shown[i] = target if target > shown[i] else max(0.0, shown[i] - VU_PEAK_DECAY)
|
|
3276
3372
|
levels.append(int(shown[i]))
|
|
3277
3373
|
send(ser, "VU:" + ",".join(str(v) for v in levels))
|
|
3374
|
+
if _sse_subs: # skip the JSON encode when nobody's listening
|
|
3375
|
+
_sse_publish({"type": "vu", "levels": levels})
|
|
3278
3376
|
finally:
|
|
3279
3377
|
discstation_burn.stop_process(proc)
|
|
3280
3378
|
|
|
@@ -4277,7 +4375,7 @@ def rip_audio_cd(ser, device):
|
|
|
4277
4375
|
|
|
4278
4376
|
|
|
4279
4377
|
def station_loop(ser):
|
|
4280
|
-
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
|
|
4378
|
+
global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
|
|
4281
4379
|
discstation_burn.cleanup_old_jobs()
|
|
4282
4380
|
try:
|
|
4283
4381
|
device = discstation_burn.disc_device()
|
|
@@ -4498,7 +4596,6 @@ def station_loop(ser):
|
|
|
4498
4596
|
|
|
4499
4597
|
mode = line.split(":", 1)[1].strip().upper()
|
|
4500
4598
|
print(f"Selected: {mode}")
|
|
4501
|
-
_web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
|
|
4502
4599
|
# The web remote's mode buttons queue START right behind SELECT: for a
|
|
4503
4600
|
# one-click burn (see app.js) - if an earlier selection was abandoned
|
|
4504
4601
|
# 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
|
@@ -106,13 +106,227 @@
|
|
|
106
106
|
? "A physical remote is attached — on-screen controls are disabled."
|
|
107
107
|
: "No physical remote detected — control DiscStation from here.";
|
|
108
108
|
$("remote-controls").querySelectorAll("button, input").forEach((el) => { el.disabled = hardware; });
|
|
109
|
-
$("remote-transport").hidden = !progress.playing;
|
|
110
|
-
const ejectBtn = $("remote-eject-btn");
|
|
111
109
|
const open = !!progress.tray_open;
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
rpLastArgs = { progress, hardware, trayOpen: open };
|
|
111
|
+
applyPlaybackPanel(progress, hardware, open);
|
|
114
112
|
}
|
|
115
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
|
+
lastVuFrameAt = 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Real VU data only ever flows on Linux (PulseAudio capture - see
|
|
219
|
+
// docs/PLATFORM_SUPPORT.md for why macOS/Windows can't) and stops the moment
|
|
220
|
+
// playback is paused even there, but `progress.playing` stays true throughout
|
|
221
|
+
// a pause - so "is a real frame actually arriving right now" has to be tracked
|
|
222
|
+
// separately, mirroring the firmware's own lastVuAt/VU_TIMEOUT_MS fallback
|
|
223
|
+
// (arduino/DiscStation/DiscStation.ino). Whenever it isn't, show a spinning
|
|
224
|
+
// disc in the same slot instead of empty/frozen bars.
|
|
225
|
+
let lastVuFrameAt = 0;
|
|
226
|
+
let rpLastIdleWithDisc = false;
|
|
227
|
+
const VU_TIMEOUT_MS = 1200;
|
|
228
|
+
function updateVuSlot() {
|
|
229
|
+
// idleWithDisc owns the slot instead (the title+modes menu) - otherwise
|
|
230
|
+
// this slot is always showing *something*: real bars while they're
|
|
231
|
+
// actually flowing, the spinning disc otherwise - covering paused,
|
|
232
|
+
// platforms with no VU capture, and plain idle-with-no-disc alike (that
|
|
233
|
+
// last one used to just sit blank).
|
|
234
|
+
if (rpLastIdleWithDisc) {
|
|
235
|
+
$("rp-bars").hidden = true;
|
|
236
|
+
$("rp-discsaver").hidden = true;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const vuFlowing = rpLastPlaying && Date.now() - lastVuFrameAt < VU_TIMEOUT_MS;
|
|
240
|
+
$("rp-bars").hidden = !vuFlowing;
|
|
241
|
+
$("rp-discsaver").hidden = vuFlowing;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let rpLastPlaying = false;
|
|
245
|
+
function applyPlaybackPanel(progress, hardware, trayOpen) {
|
|
246
|
+
const wasPlaying = rpLastPlaying;
|
|
247
|
+
rpLastPlaying = !!progress.playing;
|
|
248
|
+
const menuItems = (rpDiscInfo && rpDiscInfo.menu_items) || [];
|
|
249
|
+
|
|
250
|
+
// Mirrors the OLED's own state machine (arduino/DiscStation/DiscStation.ino):
|
|
251
|
+
// playing -> live track status + VU bars (drawPlay); idle with a disc in the
|
|
252
|
+
// drive -> the disc's name/status line + the modes it offers (drawHome); no
|
|
253
|
+
// disc at all (e.g. just ejected) -> plain status text, neither bars nor menu.
|
|
254
|
+
const idleWithDisc = !progress.playing && rpDiscInfo && rpDiscInfo.disc_present && !rpDiscInfo.busy;
|
|
255
|
+
if (idleWithDisc) {
|
|
256
|
+
$("rp-status").textContent = (rpDiscInfo.disc_title || discLineText(rpDiscInfo)).toUpperCase();
|
|
257
|
+
} else {
|
|
258
|
+
// CSS handles the visual cutoff (ellipsis) - no hard slice here so it
|
|
259
|
+
// never chops a word off abruptly mid-string.
|
|
260
|
+
$("rp-status").textContent = (progress.status || "READY").toUpperCase();
|
|
261
|
+
}
|
|
262
|
+
// Bars (or, lacking real VU data, the spinning-disc fallback) belong on
|
|
263
|
+
// screen only while something is actually playing - not just "not
|
|
264
|
+
// idle-with-a-disc" (that left them visibly stuck at their last frame once
|
|
265
|
+
// playback stopped with no disc left to show a menu for instead).
|
|
266
|
+
rpLastIdleWithDisc = idleWithDisc;
|
|
267
|
+
updateVuSlot();
|
|
268
|
+
if (wasPlaying && !progress.playing) resetVuBars(); // clear the last frame, don't leave it stuck
|
|
269
|
+
$("rp-menu").hidden = !idleWithDisc;
|
|
270
|
+
if (idleWithDisc) {
|
|
271
|
+
$("rp-menu").innerHTML = "";
|
|
272
|
+
menuItems.forEach((mode) => {
|
|
273
|
+
const line = document.createElement("div");
|
|
274
|
+
line.textContent = mode;
|
|
275
|
+
$("rp-menu").appendChild(line);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const ejectBtn = $("remote-controls").querySelector('[data-role="eject"]');
|
|
280
|
+
if (ejectBtn) { ejectBtn.dataset.cmd = trayOpen ? "CONFIRM" : "EJECT"; ejectBtn.disabled = hardware; }
|
|
281
|
+
|
|
282
|
+
// HOME/BACK: PLAY_STOP while playing, CANCEL otherwise - matches the
|
|
283
|
+
// firmware's own handleHomeButton (uiState == UI_PLAY ? "PLAY_STOP" : "CANCEL").
|
|
284
|
+
const homeBtn = $("remote-controls").querySelector('[data-role="home"]');
|
|
285
|
+
if (homeBtn) { homeBtn.dataset.cmd = progress.playing ? "PLAY_STOP" : "CANCEL"; homeBtn.disabled = hardware; }
|
|
286
|
+
|
|
287
|
+
// PLAY/PAUSE: toggle pause while playing; jump into PLAY from idle only when
|
|
288
|
+
// the disc offers it (matches handlePlayPauseButton) - disabled otherwise,
|
|
289
|
+
// e.g. correctly dead during a RIP.
|
|
290
|
+
const ppBtn = $("remote-controls").querySelector('[data-role="playpause"]');
|
|
291
|
+
if (ppBtn) {
|
|
292
|
+
const canPlay = menuItems.includes("PLAY");
|
|
293
|
+
if (progress.playing) { ppBtn.dataset.cmd = "PLAY_BUTTON"; ppBtn.disabled = hardware; }
|
|
294
|
+
else if (canPlay) { ppBtn.dataset.cmd = "SELECT:PLAY"; ppBtn.disabled = hardware; }
|
|
295
|
+
else { ppBtn.disabled = true; }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Track skip only makes sense mid-song, for an audio CD specifically (not a
|
|
299
|
+
// DVD-Video/etc. play) - /progress doesn't carry disc kind, so fall back to
|
|
300
|
+
// the cached /disc-info, which keeps reporting it while the drive is held
|
|
301
|
+
// busy for the whole play session (see _serve_disc_info's _operation_active
|
|
302
|
+
// branch).
|
|
303
|
+
const trackRow = $("rp-trackrow");
|
|
304
|
+
if (trackRow) {
|
|
305
|
+
const isAudioCd = !!(rpDiscInfo && rpDiscInfo.kind === "audio_cd");
|
|
306
|
+
trackRow.hidden = !(progress.playing && isAudioCd);
|
|
307
|
+
trackRow.querySelectorAll(".rp-btn").forEach((b) => { b.disabled = hardware; });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let rpDiscInfo = null; // latest /disc-info response - drives the idle HOME-style screen
|
|
312
|
+
function setPlaybackDiscInfo(info) {
|
|
313
|
+
rpDiscInfo = info || null;
|
|
314
|
+
if (rpLastArgs) applyPlaybackPanel(rpLastArgs.progress, rpLastArgs.hardware, rpLastArgs.trayOpen);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let rpLastArgs = null;
|
|
318
|
+
let volumeTimer;
|
|
319
|
+
$("remote-playback").addEventListener("click", (event) => {
|
|
320
|
+
const btn = event.target.closest("[data-role]");
|
|
321
|
+
if (btn && !btn.disabled) sendRemoteCmd(btn.dataset.cmd);
|
|
322
|
+
});
|
|
323
|
+
$("rp-volume").addEventListener("input", (event) => {
|
|
324
|
+
clearTimeout(volumeTimer);
|
|
325
|
+
const value = event.target.value;
|
|
326
|
+
volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
|
|
327
|
+
});
|
|
328
|
+
initRpBars();
|
|
329
|
+
|
|
116
330
|
// Firmware row: outside the remote panel on purpose - a hardware remote hides
|
|
117
331
|
// that panel, and hardware is exactly when there is a remote to update.
|
|
118
332
|
function applyFirmwareState(progress) {
|
|
@@ -139,7 +353,19 @@
|
|
|
139
353
|
} catch (_) { /* SSE/poll shows the real state */ }
|
|
140
354
|
});
|
|
141
355
|
|
|
142
|
-
|
|
356
|
+
let lastCmdKey = "", lastCmdAt = 0;
|
|
357
|
+
async function sendRemoteCmd(cmd, { allowRepeat = false } = {}) {
|
|
358
|
+
// Guard against a duplicate dispatch of the identical command landing
|
|
359
|
+
// within the same instant (e.g. a click/pointer pair both bubbling to
|
|
360
|
+
// the same handler) - a real user's next distinct press is unaffected,
|
|
361
|
+
// this only catches an exact repeat inside a tiny window. The knob's
|
|
362
|
+
// encoder ticks pass allowRepeat: true - a fast spin legitimately fires
|
|
363
|
+
// the same command (e.g. REW:BIG) several times in a row on purpose,
|
|
364
|
+
// unlike a duplicated button click.
|
|
365
|
+
const now = Date.now();
|
|
366
|
+
if (!allowRepeat && cmd === lastCmdKey && now - lastCmdAt < 250) return;
|
|
367
|
+
lastCmdKey = cmd;
|
|
368
|
+
lastCmdAt = now;
|
|
143
369
|
try {
|
|
144
370
|
await fetch("/remote/button", {
|
|
145
371
|
method: "POST",
|
|
@@ -165,12 +391,6 @@
|
|
|
165
391
|
// silently drop the START - see _handle_remote_button in discstation.py).
|
|
166
392
|
await sendRemoteCmd(cmd);
|
|
167
393
|
});
|
|
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
|
-
});
|
|
174
394
|
|
|
175
395
|
// Mode-select buttons this server-computed list governs - CANCEL/EJECT are
|
|
176
396
|
// controls, not modes, and stay available regardless (same as hardware).
|
|
@@ -191,6 +411,7 @@
|
|
|
191
411
|
const info = await response.json();
|
|
192
412
|
renderDiscStatus(info);
|
|
193
413
|
applyMenuItems(info.menu_items);
|
|
414
|
+
setPlaybackDiscInfo(info);
|
|
194
415
|
if (info.busy) return; // burn/rip in progress — keep current
|
|
195
416
|
state.discBytes = Number(info.capacity_bytes || 0);
|
|
196
417
|
state.discType = info.type || "none";
|
|
@@ -202,16 +423,19 @@
|
|
|
202
423
|
}
|
|
203
424
|
}
|
|
204
425
|
|
|
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; }
|
|
426
|
+
function discLineText(info) {
|
|
427
|
+
if (!info) return "DISC: UNKNOWN";
|
|
428
|
+
if (info.busy) return "DISC: BUSY";
|
|
429
|
+
if (!info.disc_present) return "DISC: NONE";
|
|
211
430
|
const kind = (info.type || info.kind || "unknown").toUpperCase();
|
|
212
431
|
const label = info.label ? ` "${info.label}"` : "";
|
|
213
432
|
const size = info.capacity_gb ? ` // ${info.capacity_gb}GB` : "";
|
|
214
|
-
|
|
433
|
+
return `DISC: ${kind}${label}${size}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function renderDiscStatus(info) {
|
|
437
|
+
const el = $("remote-disc-status");
|
|
438
|
+
if (el) el.textContent = discLineText(info);
|
|
215
439
|
}
|
|
216
440
|
|
|
217
441
|
function renderSelection() {
|
|
@@ -393,6 +617,7 @@
|
|
|
393
617
|
let d;
|
|
394
618
|
try { d = JSON.parse(ev.data); } catch (_) { return; }
|
|
395
619
|
if (d.type === "disc-changed") { loadDiscInfo(); return; }
|
|
620
|
+
if (d.type === "vu") { lastVuFrameAt = Date.now(); renderVuBars(d.levels); updateVuSlot(); return; }
|
|
396
621
|
setConnection(true);
|
|
397
622
|
setLiveStatus(d.status);
|
|
398
623
|
setProgress(d.status, Number(d.progress), d.active);
|
|
@@ -408,6 +633,10 @@
|
|
|
408
633
|
// Backstops: catch a zombie SSE connection, and refresh disc state slowly.
|
|
409
634
|
setInterval(() => { if (!es || es.readyState !== 1) pollStatus(); }, 8000);
|
|
410
635
|
setInterval(loadDiscInfo, 15000);
|
|
636
|
+
// Nothing else re-checks the clock once VU frames stop arriving (pause, a
|
|
637
|
+
// host platform with no capture, a dropped connection) - this is what
|
|
638
|
+
// actually notices and flips to the spinning-disc fallback promptly.
|
|
639
|
+
setInterval(updateVuSlot, 300);
|
|
411
640
|
}
|
|
412
641
|
|
|
413
642
|
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=8").catch(() => {});
|
package/src/static/index.html
CHANGED
|
@@ -56,19 +56,26 @@
|
|
|
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-discsaver" class="rp-discsaver" hidden></div>
|
|
70
|
+
<div id="rp-menu" class="rp-menu" hidden></div>
|
|
71
|
+
</div>
|
|
72
|
+
<input id="rp-volume" type="range" min="0" max="100" value="70" aria-label="Volume">
|
|
73
|
+
</div>
|
|
74
|
+
<div class="rp-buttons">
|
|
75
|
+
<button class="rp-btn" type="button" data-role="eject" aria-label="Eject"><span class="rp-glyph">⏏</span></button>
|
|
76
|
+
<button class="rp-btn" type="button" data-role="home" aria-label="Home / Back"><span class="rp-dot"></span></button>
|
|
77
|
+
<button class="rp-btn" type="button" data-role="playpause" aria-label="Play / Pause"><span class="rp-glyph">⏯</span></button>
|
|
69
78
|
</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
79
|
</div>
|
|
73
80
|
</div>
|
|
74
81
|
</section>
|
|
@@ -144,7 +151,7 @@
|
|
|
144
151
|
</footer>
|
|
145
152
|
</div>
|
|
146
153
|
<div id="install-slot"></div>
|
|
147
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
148
|
-
<script src="/static/app.js?v=
|
|
154
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=47").catch(() => {});</script>
|
|
155
|
+
<script src="/static/app.js?v=47" defer></script>
|
|
149
156
|
</body>
|
|
150
157
|
</html>
|
package/src/static/style.css
CHANGED
|
@@ -304,8 +304,136 @@ 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], .rp-discsaver[hidden] { display: none; }
|
|
390
|
+
|
|
391
|
+
/* Fallback for whenever real VU data isn't actually flowing (paused, a host
|
|
392
|
+
platform with no audio-capture support, or just-started-playing before the
|
|
393
|
+
first frame arrives) - see updateVuSlot() in app.js. A CSS-only spinning
|
|
394
|
+
vinyl-record glyph, matching this page's existing all-CSS/Unicode-glyph
|
|
395
|
+
iconography (no SVG/bitmap assets anywhere else in this file). */
|
|
396
|
+
.rp-discsaver {
|
|
397
|
+
flex: 1;
|
|
398
|
+
display: flex;
|
|
399
|
+
align-items: center;
|
|
400
|
+
justify-content: center;
|
|
401
|
+
border-top: 1px solid #262626;
|
|
402
|
+
padding-top: 4px;
|
|
403
|
+
}
|
|
404
|
+
.rp-discsaver::before {
|
|
405
|
+
content: "";
|
|
406
|
+
width: 34px;
|
|
407
|
+
height: 34px;
|
|
408
|
+
border-radius: 50%;
|
|
409
|
+
border: 2px solid var(--accent);
|
|
410
|
+
background: radial-gradient(circle, #0a0a0a 0 22%, transparent 23%),
|
|
411
|
+
repeating-radial-gradient(var(--ink) 0 1px, transparent 1px 4px);
|
|
412
|
+
box-shadow: 0 0 6px var(--accent);
|
|
413
|
+
animation: rp-disc-spin 2.4s linear infinite;
|
|
414
|
+
}
|
|
415
|
+
@keyframes rp-disc-spin { to { transform: rotate(360deg); } }
|
|
416
|
+
.rp-main input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|
417
|
+
.rp-buttons { display: flex; flex-direction: column; gap: 8px; }
|
|
418
|
+
.rp-btn {
|
|
419
|
+
width: 52px;
|
|
420
|
+
height: 52px;
|
|
421
|
+
border: 1px solid var(--ink);
|
|
422
|
+
background: var(--surface);
|
|
423
|
+
cursor: pointer;
|
|
424
|
+
display: flex;
|
|
425
|
+
align-items: center;
|
|
426
|
+
justify-content: center;
|
|
427
|
+
transition: background 0.15s ease;
|
|
428
|
+
}
|
|
429
|
+
.rp-btn:hover { background: var(--paper-deep); }
|
|
430
|
+
.rp-btn:active { background: var(--accent); }
|
|
431
|
+
.rp-btn:disabled { cursor: not-allowed; opacity: 0.4; }
|
|
432
|
+
.rp-glyph { font-size: 22px; color: var(--ink); }
|
|
433
|
+
.rp-dot { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--ink); }
|
|
434
|
+
.rp-trackrow { display: flex; gap: 8px; }
|
|
435
|
+
.rp-trackrow[hidden] { display: none; }
|
|
436
|
+
.rp-trackbtn { flex: 1; width: auto; height: 32px; }
|
|
309
437
|
|
|
310
438
|
.footer-stamp {
|
|
311
439
|
display: flex;
|