discstation 0.1.43 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,9 +172,12 @@ self-signed cert. On startup the host prints the LAN URL to open
172
172
  tag next to the wordmark any time). Mode buttons only show what the disc
173
173
  in the drive can actually do — a blank CD offers BURN DATA/BURN AUDIO, a
174
174
  blank DVD offers BURN/BURN DATA, an already-recorded disc offers PLAY/RIP
175
- — and picking one starts it directly, no separate confirm step. 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). EJECT/CLOSE toggle,
179
+ volume, and full playback transport including PREV/NEXT track for audio
180
+ CDs. Greys out and auto-collapses the instant a physical remote is
178
181
  detected, so the two never fight for control — including mid-burn/rip/play:
179
182
  plugging the remote in hands it control without interrupting whatever's
180
183
  running, and its screen picks up the current status instead of starting
Binary file
@@ -1 +1 @@
1
- {"version":"0.1.43","size":1260592,"md5":"575faf0891e354ecd451d8454228a18e"}
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.43",
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,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:
@@ -599,6 +615,10 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
599
615
  info["type"] = di.web_type
600
616
  info["kind"] = di.kind
601
617
  info["label"] = di.label
618
+ # Same lookup the OLED's DISC_NAME: already uses (TOC-fingerprint match
619
+ # against burn history for an audio CD, volume label for a data disc/DVD) -
620
+ # richer than "label" above, which is empty for most audio CDs.
621
+ info["disc_title"] = disc_title(device) if di.present and not di.transient else ""
602
622
  info["menu_items"] = menu_items_for_disc(device) if di.present and not di.transient else []
603
623
  except Exception as e:
604
624
  print(f"Disc info error: {e}")
@@ -653,7 +673,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
653
673
  def _serve_sw(self):
654
674
  sw = '''self.addEventListener('install', e => {
655
675
  self.skipWaiting();
656
- caches.open('discstation-v20').then(c => c.addAll(['/','/static/style.css?v=20','/static/app.js?v=20']));
676
+ caches.open('discstation-v46').then(c => c.addAll(['/','/static/style.css?v=46','/static/app.js?v=46']));
657
677
  });
658
678
  self.addEventListener('activate', e => e.waitUntil(clients.claim()));
659
679
  self.addEventListener('fetch', e => {
@@ -662,7 +682,7 @@ self.addEventListener('fetch', e => {
662
682
  if (path === '/' || path.startsWith('/static/')) {
663
683
  e.respondWith(fetch(e.request).then(r => {
664
684
  const copy = r.clone();
665
- caches.open('discstation-v20').then(c => c.put(e.request, copy));
685
+ caches.open('discstation-v46').then(c => c.put(e.request, copy));
666
686
  return r;
667
687
  }).catch(() => caches.match(e.request)));
668
688
  } else {
@@ -1077,7 +1097,7 @@ def _set_web_progress(phase, percent=-1):
1077
1097
 
1078
1098
 
1079
1099
  def _record_web_status(msg):
1080
- global _web_status, _web_progress, _web_progress_active, _web_playing
1100
+ global _web_status, _web_progress, _web_progress_active, _web_playing, _web_phase
1081
1101
  _remember(msg)
1082
1102
  if msg.startswith("DISC:"):
1083
1103
  _sse_publish({"type": "disc-changed"})
@@ -1088,12 +1108,20 @@ def _record_web_status(msg):
1088
1108
  _web_progress_active = True
1089
1109
  elif msg.startswith("PLAY_STATUS:") or msg.startswith("PLAY:"):
1090
1110
  _web_status = msg.split(":", 1)[1].strip() or "PLAYING"
1111
+ elif msg.startswith("WAITING:"):
1112
+ # The OLED's pre-burn confirm screen ("does this fit? confirm to
1113
+ # go") - was silently dropped before (not in this elif chain at
1114
+ # all), which is exactly why a burn stuck here looked frozen on
1115
+ # the web instead of showing what it's actually waiting on.
1116
+ _web_status = "Waiting to confirm: " + (msg[8:].strip() or "...")
1117
+ _web_progress_active = True
1091
1118
  elif msg.startswith("STATUS:"):
1092
1119
  _web_status = msg[7:].strip() or "READY"
1120
+ _web_phase = _web_status.rstrip(".")
1093
1121
  _web_progress_active = True
1094
1122
  elif msg.startswith("PROGRESS:"):
1095
1123
  value = msg[9:].strip()
1096
- _web_status = f"{_web_op_verb} {value}"
1124
+ _web_status = f"{_web_phase} {value}"
1097
1125
  match = re.search(r"(\d+(?:\.\d+)?)", value)
1098
1126
  if match:
1099
1127
  _web_progress = min(100, max(0, int(float(match.group(1)))))
@@ -1103,14 +1131,17 @@ def _record_web_status(msg):
1103
1131
  _web_progress = 100
1104
1132
  _web_progress_active = False
1105
1133
  _web_playing = False
1134
+ _web_phase = "READY"
1106
1135
  elif msg.startswith("ERROR:"):
1107
1136
  _web_status = msg[6:].strip() or "ERROR"
1108
1137
  _web_progress_active = False
1109
1138
  _web_playing = False
1139
+ _web_phase = "READY"
1110
1140
  elif msg.startswith("CANCELLED:"):
1111
1141
  _web_status = msg[10:].strip() or "CANCELLED"
1112
1142
  _web_progress_active = False
1113
1143
  _web_playing = False
1144
+ _web_phase = "READY"
1114
1145
  elif msg.startswith(("STANDBY:", "HOME:")):
1115
1146
  # idle again (tray open, insert disc, back to the menu) — clear any
1116
1147
  # lingering "Ejecting..." / progress state on the web UI.
@@ -1119,6 +1150,7 @@ def _record_web_status(msg):
1119
1150
  _web_progress = -1
1120
1151
  _web_progress_active = False
1121
1152
  _web_playing = False
1153
+ _web_phase = "READY"
1122
1154
  else:
1123
1155
  return
1124
1156
  _sse_publish(_status_snapshot())
@@ -1504,14 +1536,17 @@ def _wait_for_start(ser, starting=None, mode=None):
1504
1536
 
1505
1537
  def run_probe(cmd, timeout=8, name=None):
1506
1538
  try:
1507
- return subprocess.run(
1508
- cmd,
1509
- capture_output=True,
1510
- text=True,
1511
- timeout=timeout,
1539
+ return _run_hard_timeout(
1540
+ lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
1541
+ timeout=timeout + 5,
1512
1542
  )
1513
1543
  except subprocess.TimeoutExpired as e:
1514
1544
  return subprocess.CompletedProcess(cmd, 124, ensure_text(e.stdout), ensure_text(e.stderr))
1545
+ except RuntimeError:
1546
+ # _run_hard_timeout gave up on a child stuck in D-state (uninterruptible
1547
+ # kernel I/O sleep) rather than a clean subprocess-level timeout - same
1548
+ # "this probe didn't answer" outcome from every caller's point of view.
1549
+ return subprocess.CompletedProcess(cmd, 124, "", "")
1515
1550
  except (FileNotFoundError, OSError) as e:
1516
1551
  return subprocess.CompletedProcess(cmd, 127, "", str(e))
1517
1552
 
@@ -1600,6 +1635,9 @@ def udev_cdrom_properties(device, refresh=False):
1600
1635
  return properties
1601
1636
 
1602
1637
 
1638
+ discstation_burn.udev_cdrom_properties = udev_cdrom_properties
1639
+
1640
+
1603
1641
  _tray_open = False
1604
1642
  _tray_open_since = 0.0 # time.monotonic() of the last OLED-initiated eject
1605
1643
 
@@ -2198,6 +2236,32 @@ def menu_items_for_disc(device):
2198
2236
  return items
2199
2237
 
2200
2238
 
2239
+ def _run_hard_timeout(fn, timeout):
2240
+ """Run fn() on a throwaway daemon thread and bound the wait on the THREAD,
2241
+ not on whatever fn() calls internally. subprocess.run(timeout=N) can't
2242
+ rescue a caller from a child stuck in D-state (uninterruptible kernel I/O
2243
+ sleep - e.g. a drive that can't read a bad/dirty disc): SIGKILL doesn't
2244
+ interrupt D-state, so the library's own kill-then-reap after its own
2245
+ timeout hangs right along with it. This is the only way to guarantee the
2246
+ caller gets control back - the thread (and whatever it's still stuck in)
2247
+ is abandoned/leaked, but nothing ever waits on it again, so any lock the
2248
+ caller holds gets released either way."""
2249
+ result = {}
2250
+ def _target():
2251
+ try:
2252
+ result["value"] = fn()
2253
+ except Exception as e:
2254
+ result["error"] = e
2255
+ t = threading.Thread(target=_target, daemon=True)
2256
+ t.start()
2257
+ t.join(timeout)
2258
+ if t.is_alive():
2259
+ raise RuntimeError("drive not responding (timed out)")
2260
+ if "error" in result:
2261
+ raise result["error"]
2262
+ return result["value"]
2263
+
2264
+
2201
2265
  class mounted_disc:
2202
2266
  def __init__(self, device):
2203
2267
  self.device = device
@@ -2228,7 +2292,14 @@ class mounted_disc:
2228
2292
  else:
2229
2293
  self.tmp.cleanup()
2230
2294
  raise RuntimeError("Disc mounting backend is not configured for this operating system")
2231
- 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
2232
2303
  if result.returncode != 0:
2233
2304
  self.tmp.cleanup()
2234
2305
  raise RuntimeError((result.stderr or result.stdout or "Could not mount disc").strip())
@@ -2980,7 +3051,7 @@ def burn_flow(ser):
2980
3051
  )
2981
3052
 
2982
3053
  if plan["burn"]:
2983
- discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes)
3054
+ discstation_burn.wait_for_burn_confirm(ser, dvd_dir, disc_bytes, auto_confirm=ser.hw is None)
2984
3055
  discstation_burn.burn(ser, dvd_dir, disc_label, burn_speed, dl_info["is_dual_layer"])
2985
3056
  safe_send(ser, "DONE:Disc complete!")
2986
3057
  print("Burn complete.")
@@ -3249,8 +3320,8 @@ def _vu_loop(ser, stop_event, pause_event):
3249
3320
  if proc.poll() is not None:
3250
3321
  break
3251
3322
  continue
3252
- if pause_event.is_set() or ser.hw is None: # nobody to draw bars for
3253
- continue
3323
+ if pause_event.is_set() or (ser.hw is None and not _sse_subs):
3324
+ continue # nobody to draw bars for - no OLED, no browser watching either
3254
3325
  samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
3255
3326
  spectrum = _np.abs(_np.fft.rfft(samples * window))
3256
3327
  mags = []
@@ -3275,6 +3346,8 @@ def _vu_loop(ser, stop_event, pause_event):
3275
3346
  shown[i] = target if target > shown[i] else max(0.0, shown[i] - VU_PEAK_DECAY)
3276
3347
  levels.append(int(shown[i]))
3277
3348
  send(ser, "VU:" + ",".join(str(v) for v in levels))
3349
+ if _sse_subs: # skip the JSON encode when nobody's listening
3350
+ _sse_publish({"type": "vu", "levels": levels})
3278
3351
  finally:
3279
3352
  discstation_burn.stop_process(proc)
3280
3353
 
@@ -4277,7 +4350,7 @@ def rip_audio_cd(ser, device):
4277
4350
 
4278
4351
 
4279
4352
  def station_loop(ser):
4280
- global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active, _web_op_verb
4353
+ global _last_burn_result, _last_burn_result_time, _tray_open, _tray_open_since, _operation_active
4281
4354
  discstation_burn.cleanup_old_jobs()
4282
4355
  try:
4283
4356
  device = discstation_burn.disc_device()
@@ -4498,7 +4571,6 @@ def station_loop(ser):
4498
4571
 
4499
4572
  mode = line.split(":", 1)[1].strip().upper()
4500
4573
  print(f"Selected: {mode}")
4501
- _web_op_verb = "RIPPING" if mode == "RIP" else "BURNING"
4502
4574
  # The web remote's mode buttons queue START right behind SELECT: for a
4503
4575
  # one-click burn (see app.js) - if an earlier selection was abandoned
4504
4576
  # before its own START got consumed (e.g. never uploaded/confirmed a
@@ -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,198 @@
106
106
  ? "A physical remote is attached — on-screen controls are disabled."
107
107
  : "No physical remote detected — control DiscStation from here.";
108
108
  $("remote-controls").querySelectorAll("button, input").forEach((el) => { el.disabled = hardware; });
109
- $("remote-transport").hidden = !progress.playing;
110
- const ejectBtn = $("remote-eject-btn");
111
109
  const open = !!progress.tray_open;
112
- 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
+ }
216
+
217
+ let rpLastPlaying = false;
218
+ function applyPlaybackPanel(progress, hardware, trayOpen) {
219
+ const wasPlaying = rpLastPlaying;
220
+ rpLastPlaying = !!progress.playing;
221
+ const menuItems = (rpDiscInfo && rpDiscInfo.menu_items) || [];
222
+
223
+ // Mirrors the OLED's own state machine (arduino/DiscStation/DiscStation.ino):
224
+ // playing -> live track status + VU bars (drawPlay); idle with a disc in the
225
+ // drive -> the disc's name/status line + the modes it offers (drawHome); no
226
+ // disc at all (e.g. just ejected) -> plain status text, neither bars nor menu.
227
+ const idleWithDisc = !progress.playing && rpDiscInfo && rpDiscInfo.disc_present && !rpDiscInfo.busy;
228
+ if (idleWithDisc) {
229
+ $("rp-status").textContent = (rpDiscInfo.disc_title || discLineText(rpDiscInfo)).toUpperCase();
230
+ } else {
231
+ // CSS handles the visual cutoff (ellipsis) - no hard slice here so it
232
+ // never chops a word off abruptly mid-string.
233
+ $("rp-status").textContent = (progress.status || "READY").toUpperCase();
234
+ }
235
+ // Bars belong on screen only while something is actually playing - not just
236
+ // "not idle-with-a-disc" (that left them visibly stuck at their last frame
237
+ // once playback stopped with no disc left to show a menu for instead).
238
+ $("rp-bars").hidden = !progress.playing;
239
+ if (wasPlaying && !progress.playing) resetVuBars(); // clear the last frame, don't leave it stuck
240
+ $("rp-menu").hidden = !idleWithDisc;
241
+ if (idleWithDisc) {
242
+ $("rp-menu").innerHTML = "";
243
+ menuItems.forEach((mode) => {
244
+ const line = document.createElement("div");
245
+ line.textContent = mode;
246
+ $("rp-menu").appendChild(line);
247
+ });
248
+ }
249
+
250
+ const ejectBtn = $("remote-controls").querySelector('[data-role="eject"]');
251
+ if (ejectBtn) { ejectBtn.dataset.cmd = trayOpen ? "CONFIRM" : "EJECT"; ejectBtn.disabled = hardware; }
252
+
253
+ // HOME/BACK: PLAY_STOP while playing, CANCEL otherwise - matches the
254
+ // firmware's own handleHomeButton (uiState == UI_PLAY ? "PLAY_STOP" : "CANCEL").
255
+ const homeBtn = $("remote-controls").querySelector('[data-role="home"]');
256
+ if (homeBtn) { homeBtn.dataset.cmd = progress.playing ? "PLAY_STOP" : "CANCEL"; homeBtn.disabled = hardware; }
257
+
258
+ // PLAY/PAUSE: toggle pause while playing; jump into PLAY from idle only when
259
+ // the disc offers it (matches handlePlayPauseButton) - disabled otherwise,
260
+ // e.g. correctly dead during a RIP.
261
+ const ppBtn = $("remote-controls").querySelector('[data-role="playpause"]');
262
+ if (ppBtn) {
263
+ const canPlay = menuItems.includes("PLAY");
264
+ if (progress.playing) { ppBtn.dataset.cmd = "PLAY_BUTTON"; ppBtn.disabled = hardware; }
265
+ else if (canPlay) { ppBtn.dataset.cmd = "SELECT:PLAY"; ppBtn.disabled = hardware; }
266
+ else { ppBtn.disabled = true; }
267
+ }
268
+
269
+ // Track skip only makes sense mid-song, for an audio CD specifically (not a
270
+ // DVD-Video/etc. play) - /progress doesn't carry disc kind, so fall back to
271
+ // the cached /disc-info, which keeps reporting it while the drive is held
272
+ // busy for the whole play session (see _serve_disc_info's _operation_active
273
+ // branch).
274
+ const trackRow = $("rp-trackrow");
275
+ if (trackRow) {
276
+ const isAudioCd = !!(rpDiscInfo && rpDiscInfo.kind === "audio_cd");
277
+ trackRow.hidden = !(progress.playing && isAudioCd);
278
+ trackRow.querySelectorAll(".rp-btn").forEach((b) => { b.disabled = hardware; });
279
+ }
280
+ }
281
+
282
+ let rpDiscInfo = null; // latest /disc-info response - drives the idle HOME-style screen
283
+ function setPlaybackDiscInfo(info) {
284
+ rpDiscInfo = info || null;
285
+ if (rpLastArgs) applyPlaybackPanel(rpLastArgs.progress, rpLastArgs.hardware, rpLastArgs.trayOpen);
286
+ }
287
+
288
+ let rpLastArgs = null;
289
+ let volumeTimer;
290
+ $("remote-playback").addEventListener("click", (event) => {
291
+ const btn = event.target.closest("[data-role]");
292
+ if (btn && !btn.disabled) sendRemoteCmd(btn.dataset.cmd);
293
+ });
294
+ $("rp-volume").addEventListener("input", (event) => {
295
+ clearTimeout(volumeTimer);
296
+ const value = event.target.value;
297
+ volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
298
+ });
299
+ initRpBars();
300
+
116
301
  // Firmware row: outside the remote panel on purpose - a hardware remote hides
117
302
  // that panel, and hardware is exactly when there is a remote to update.
118
303
  function applyFirmwareState(progress) {
@@ -139,7 +324,19 @@
139
324
  } catch (_) { /* SSE/poll shows the real state */ }
140
325
  });
141
326
 
142
- 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;
143
340
  try {
144
341
  await fetch("/remote/button", {
145
342
  method: "POST",
@@ -165,12 +362,6 @@
165
362
  // silently drop the START - see _handle_remote_button in discstation.py).
166
363
  await sendRemoteCmd(cmd);
167
364
  });
168
- let volumeTimer;
169
- $("remote-volume").addEventListener("input", (event) => {
170
- clearTimeout(volumeTimer);
171
- const value = event.target.value;
172
- volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
173
- });
174
365
 
175
366
  // Mode-select buttons this server-computed list governs - CANCEL/EJECT are
176
367
  // controls, not modes, and stay available regardless (same as hardware).
@@ -191,6 +382,7 @@
191
382
  const info = await response.json();
192
383
  renderDiscStatus(info);
193
384
  applyMenuItems(info.menu_items);
385
+ setPlaybackDiscInfo(info);
194
386
  if (info.busy) return; // burn/rip in progress — keep current
195
387
  state.discBytes = Number(info.capacity_bytes || 0);
196
388
  state.discType = info.type || "none";
@@ -202,16 +394,19 @@
202
394
  }
203
395
  }
204
396
 
205
- function 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=20").catch(() => {});</script>
148
- <script src="/static/app.js?v=20" 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;