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 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 shows a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`); while a track plays the OLED keeps the track/volume screen (or the visualizer) up — the spinning-disc screensaver only appears on the home/standby screens or after playback has been paused for 15 s |
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. Also has
176
- EJECT/CLOSE toggle, a live disc-status readout, and full playback
177
- transport. Greys out and auto-collapses the instant a physical remote is
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. Same auto-collapse behavior
191
- when a physical ESP32 shows up — mid-operation and without interruption, same
192
- as the web UI — and the same underlying `/remote/button` protocol.
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.43","size":1260592,"md5":"575faf0891e354ecd451d8454228a18e"}
1
+ {"version":"0.3.0","size":1260592,"md5":"e0959615d438880783baf38f8943ae3f"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.43",
3
+ "version": "0.3.0",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -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
- _web_op_verb = "BURNING" # progress-bar verb for the current PROGRESS: stream
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: chain START right behind SELECT:BURN* in the SAME
534
- # request, atomically - station_loop's SELECT handler clears any
535
- # stale buffered input the instant it reads the SELECT line, and a
536
- # START sent as a second, separate HTTP call can land in that exact
537
- # gap and get wiped (intermittent, timing-dependent "burn never
538
- # starts" bug). Queuing both lines from one call removes the gap.
539
- if cmd.upper().startswith("SELECT:BURN"):
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
- device = discstation_burn.disc_device()
590
- di = detect_disc(device, settle=False, budget=15)
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-v20').then(c => c.addAll(['/','/static/style.css?v=20','/static/app.js?v=20']));
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-v20').then(c => c.put(e.request, copy));
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"{_web_op_verb} {value}"
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 subprocess.run(
1508
- cmd,
1509
- capture_output=True,
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
- result = subprocess.run(command, capture_output=True, text=True, timeout=30)
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: # nobody to draw bars for
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, _web_op_verb
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
@@ -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). Lazy import to avoid the import cycle with discstation.
374
- try:
375
- from discstation import udev_cdrom_properties
376
- return udev_cdrom_properties(device)
377
- except Exception:
378
- pass
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
- ejectBtn.textContent = open ? "CLOSE TRAY" : "EJECT";
113
- ejectBtn.dataset.cmd = open ? "CONFIRM" : "EJECT";
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
- async function sendRemoteCmd(cmd) {
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 renderDiscStatus(info) {
206
- const el = $("remote-disc-status");
207
- if (!el) return;
208
- if (!info) { el.textContent = "DISC: UNKNOWN"; return; }
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
- el.textContent = `DISC: ${kind}${label}${size}`;
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(() => {});
@@ -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-transport" class="remote-transport" hidden>
64
- <div class="remote-grid">
65
- <button class="outline-button" type="button" data-cmd="REW:BIG">&#x23EE; PREV</button>
66
- <button class="outline-button" type="button" data-cmd="PLAY_BUTTON">&#x23EF; PLAY/PAUSE</button>
67
- <button class="outline-button" type="button" data-cmd="FF:BIG">&#x23ED; NEXT</button>
68
- <button class="outline-button" type="button" data-cmd="PLAY_STOP">&#x23F9; STOP</button>
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">&#x23EE;</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">&#x23ED;</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">&#x23CF;</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">&#x23EF;</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=20").catch(() => {});</script>
148
- <script src="/static/app.js?v=20" defer></script>
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>
@@ -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
- .remote-transport { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--soft-line); }
308
- .remote-transport input[type="range"] { width: 100%; margin-top: 4px; accent-color: var(--accent); }
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;