discstation 0.1.43 → 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 +7 -4
- package/arduino/firmware/discstation.bin +0 -0
- package/arduino/firmware/firmware.json +1 -1
- package/package.json +1 -1
- package/src/discstation.py +95 -23
- package/src/discstation_burn.py +25 -7
- package/src/static/app.js +214 -18
- 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,9 +172,12 @@ 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). 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
|
|
178
181
|
detected, so the two never fight for control — including mid-burn/rip/play:
|
|
179
182
|
plugging the remote in hands it control without interrupting whatever's
|
|
180
183
|
running, and its screen picks up the current status instead of starting
|
|
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,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:
|
|
@@ -599,6 +615,10 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
599
615
|
info["type"] = di.web_type
|
|
600
616
|
info["kind"] = di.kind
|
|
601
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 ""
|
|
602
622
|
info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
|
|
603
623
|
except Exception as e:
|
|
604
624
|
print(f"Disc info error: {e}")
|
|
@@ -653,7 +673,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
653
673
|
def _serve_sw(self):
|
|
654
674
|
sw = '''self.addEventListener('install', e => {
|
|
655
675
|
self.skipWaiting();
|
|
656
|
-
caches.open('discstation-
|
|
676
|
+
caches.open('discstation-v46').then(c => c.addAll(['/','/static/style.css?v=46','/static/app.js?v=46']));
|
|
657
677
|
});
|
|
658
678
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
659
679
|
self.addEventListener('fetch', e => {
|
|
@@ -662,7 +682,7 @@ self.addEventListener('fetch', e => {
|
|
|
662
682
|
if (path === '/' || path.startsWith('/static/')) {
|
|
663
683
|
e.respondWith(fetch(e.request).then(r => {
|
|
664
684
|
const copy = r.clone();
|
|
665
|
-
caches.open('discstation-
|
|
685
|
+
caches.open('discstation-v46').then(c => c.put(e.request, copy));
|
|
666
686
|
return r;
|
|
667
687
|
}).catch(() => caches.match(e.request)));
|
|
668
688
|
} else {
|
|
@@ -1077,7 +1097,7 @@ def _set_web_progress(phase, percent=-1):
|
|
|
1077
1097
|
|
|
1078
1098
|
|
|
1079
1099
|
def _record_web_status(msg):
|
|
1080
|
-
global _web_status, _web_progress, _web_progress_active, _web_playing
|
|
1100
|
+
global _web_status, _web_progress, _web_progress_active, _web_playing, _web_phase
|
|
1081
1101
|
_remember(msg)
|
|
1082
1102
|
if msg.startswith("DISC:"):
|
|
1083
1103
|
_sse_publish({"type": "disc-changed"})
|
|
@@ -1088,12 +1108,20 @@ def _record_web_status(msg):
|
|
|
1088
1108
|
_web_progress_active = True
|
|
1089
1109
|
elif msg.startswith("PLAY_STATUS:") or msg.startswith("PLAY:"):
|
|
1090
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
|
|
1091
1118
|
elif msg.startswith("STATUS:"):
|
|
1092
1119
|
_web_status = msg[7:].strip() or "READY"
|
|
1120
|
+
_web_phase = _web_status.rstrip(".")
|
|
1093
1121
|
_web_progress_active = True
|
|
1094
1122
|
elif msg.startswith("PROGRESS:"):
|
|
1095
1123
|
value = msg[9:].strip()
|
|
1096
|
-
_web_status = f"{
|
|
1124
|
+
_web_status = f"{_web_phase} {value}"
|
|
1097
1125
|
match = re.search(r"(\d+(?:\.\d+)?)", value)
|
|
1098
1126
|
if match:
|
|
1099
1127
|
_web_progress = min(100, max(0, int(float(match.group(1)))))
|
|
@@ -1103,14 +1131,17 @@ def _record_web_status(msg):
|
|
|
1103
1131
|
_web_progress = 100
|
|
1104
1132
|
_web_progress_active = False
|
|
1105
1133
|
_web_playing = False
|
|
1134
|
+
_web_phase = "READY"
|
|
1106
1135
|
elif msg.startswith("ERROR:"):
|
|
1107
1136
|
_web_status = msg[6:].strip() or "ERROR"
|
|
1108
1137
|
_web_progress_active = False
|
|
1109
1138
|
_web_playing = False
|
|
1139
|
+
_web_phase = "READY"
|
|
1110
1140
|
elif msg.startswith("CANCELLED:"):
|
|
1111
1141
|
_web_status = msg[10:].strip() or "CANCELLED"
|
|
1112
1142
|
_web_progress_active = False
|
|
1113
1143
|
_web_playing = False
|
|
1144
|
+
_web_phase = "READY"
|
|
1114
1145
|
elif msg.startswith(("STANDBY:", "HOME:")):
|
|
1115
1146
|
# idle again (tray open, insert disc, back to the menu) — clear any
|
|
1116
1147
|
# lingering "Ejecting..." / progress state on the web UI.
|
|
@@ -1119,6 +1150,7 @@ def _record_web_status(msg):
|
|
|
1119
1150
|
_web_progress = -1
|
|
1120
1151
|
_web_progress_active = False
|
|
1121
1152
|
_web_playing = False
|
|
1153
|
+
_web_phase = "READY"
|
|
1122
1154
|
else:
|
|
1123
1155
|
return
|
|
1124
1156
|
_sse_publish(_status_snapshot())
|
|
@@ -1504,14 +1536,17 @@ def _wait_for_start(ser, starting=None, mode=None):
|
|
|
1504
1536
|
|
|
1505
1537
|
def run_probe(cmd, timeout=8, name=None):
|
|
1506
1538
|
try:
|
|
1507
|
-
return
|
|
1508
|
-
cmd,
|
|
1509
|
-
|
|
1510
|
-
text=True,
|
|
1511
|
-
timeout=timeout,
|
|
1539
|
+
return _run_hard_timeout(
|
|
1540
|
+
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
|
|
1541
|
+
timeout=timeout + 5,
|
|
1512
1542
|
)
|
|
1513
1543
|
except subprocess.TimeoutExpired as e:
|
|
1514
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, "", "")
|
|
1515
1550
|
except (FileNotFoundError, OSError) as e:
|
|
1516
1551
|
return subprocess.CompletedProcess(cmd, 127, "", str(e))
|
|
1517
1552
|
|
|
@@ -1600,6 +1635,9 @@ def udev_cdrom_properties(device, refresh=False):
|
|
|
1600
1635
|
return properties
|
|
1601
1636
|
|
|
1602
1637
|
|
|
1638
|
+
discstation_burn.udev_cdrom_properties = udev_cdrom_properties
|
|
1639
|
+
|
|
1640
|
+
|
|
1603
1641
|
_tray_open = False
|
|
1604
1642
|
_tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
|
|
1605
1643
|
|
|
@@ -2198,6 +2236,32 @@ def menu_items_for_disc(device):
|
|
|
2198
2236
|
return items
|
|
2199
2237
|
|
|
2200
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
|
+
|
|
2201
2265
|
class mounted_disc:
|
|
2202
2266
|
def __init__(self, device):
|
|
2203
2267
|
self.device = device
|
|
@@ -2228,7 +2292,14 @@ class mounted_disc:
|
|
|
2228
2292
|
else:
|
|
2229
2293
|
self.tmp.cleanup()
|
|
2230
2294
|
raise RuntimeError("Disc mounting backend is not configured for this operating system")
|
|
2231
|
-
|
|
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
|
|
2232
2303
|
if result.returncode != 0:
|
|
2233
2304
|
self.tmp.cleanup()
|
|
2234
2305
|
raise RuntimeError((result.stderr or result.stdout or "Could not mount disc").strip())
|
|
@@ -2980,7 +3051,7 @@ def burn_flow(ser):
|
|
|
2980
3051
|
)
|
|
2981
3052
|
|
|
2982
3053
|
if plan["burn"]:
|
|
2983
|
-
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)
|
|
2984
3055
|
discstation_burn.burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
|
|
2985
3056
|
safe_send(ser, "DONE:Disc complete!")
|
|
2986
3057
|
print("Burn complete.")
|
|
@@ -3249,8 +3320,8 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3249
3320
|
if proc.poll() is not None:
|
|
3250
3321
|
break
|
|
3251
3322
|
continue
|
|
3252
|
-
if pause_event.is_set() or ser.hw is None
|
|
3253
|
-
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
|
|
3254
3325
|
samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
|
|
3255
3326
|
spectrum = _np.abs(_np.fft.rfft(samples * window))
|
|
3256
3327
|
mags = []
|
|
@@ -3275,6 +3346,8 @@ def _vu_loop(ser, stop_event, pause_event):
|
|
|
3275
3346
|
shown[i] = target if target > shown[i] else max(0.0, shown[i] - VU_PEAK_DECAY)
|
|
3276
3347
|
levels.append(int(shown[i]))
|
|
3277
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})
|
|
3278
3351
|
finally:
|
|
3279
3352
|
discstation_burn.stop_process(proc)
|
|
3280
3353
|
|
|
@@ -4277,7 +4350,7 @@ def rip_audio_cd(ser, device):
|
|
|
4277
4350
|
|
|
4278
4351
|
|
|
4279
4352
|
def station_loop(ser):
|
|
4280
|
-
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
|
|
4281
4354
|
discstation_burn.cleanup_old_jobs()
|
|
4282
4355
|
try:
|
|
4283
4356
|
device = discstation_burn.disc_device()
|
|
@@ -4498,7 +4571,6 @@ def station_loop(ser):
|
|
|
4498
4571
|
|
|
4499
4572
|
mode = line.split(":", 1)[1].strip().upper()
|
|
4500
4573
|
print(f"Selected: {mode}")
|
|
4501
|
-
_web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
|
|
4502
4574
|
# The web remote's mode buttons queue START right behind SELECT: for a
|
|
4503
4575
|
# one-click burn (see app.js) - if an earlier selection was abandoned
|
|
4504
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
|
@@ -106,13 +106,198 @@
|
|
|
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
|
+
}
|
|
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
|
+
|
|
116
301
|
// Firmware row: outside the remote panel on purpose - a hardware remote hides
|
|
117
302
|
// that panel, and hardware is exactly when there is a remote to update.
|
|
118
303
|
function applyFirmwareState(progress) {
|
|
@@ -139,7 +324,19 @@
|
|
|
139
324
|
} catch (_) { /* SSE/poll shows the real state */ }
|
|
140
325
|
});
|
|
141
326
|
|
|
142
|
-
|
|
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;
|
|
143
340
|
try {
|
|
144
341
|
await fetch("/remote/button", {
|
|
145
342
|
method: "POST",
|
|
@@ -165,12 +362,6 @@
|
|
|
165
362
|
// silently drop the START - see _handle_remote_button in discstation.py).
|
|
166
363
|
await sendRemoteCmd(cmd);
|
|
167
364
|
});
|
|
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
365
|
|
|
175
366
|
// Mode-select buttons this server-computed list governs - CANCEL/EJECT are
|
|
176
367
|
// controls, not modes, and stay available regardless (same as hardware).
|
|
@@ -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;
|