discstation 0.1.42 → 0.2.0

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