discstation 0.1.18 → 0.1.21

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.
@@ -520,6 +520,8 @@ status_sink = None
520
520
 
521
521
  def send(ser, msg):
522
522
  global _serial_write_failed
523
+ if os.environ.get("DISCSTATION_DEBUG_SERIAL"):
524
+ print(f"[{time.time():.3f}] SEND {msg!r}", flush=True)
523
525
  if status_sink is not None:
524
526
  try:
525
527
  status_sink(msg)
@@ -719,7 +721,7 @@ def get_local_video_info(path):
719
721
  return {"title": stem, "duration": dur}
720
722
 
721
723
 
722
- def get_video_info(source):
724
+ def get_video_info(source, ser=None):
723
725
  p = Path(source)
724
726
  if p.is_dir():
725
727
  videos = find_video_files(source)
@@ -731,11 +733,25 @@ def get_video_info(source):
731
733
  return get_local_video_info(source)
732
734
  errors = []
733
735
  for player_client in YTDLP_PLAYER_CLIENTS or (None,):
734
- r = subprocess.run(
735
- [*ytdlp_base_args(player_client), '--dump-single-json', '--skip-download', source],
736
- capture_output=True,
737
- text=True,
738
- )
736
+ # This yt-dlp metadata fetch can take a while (retries, slow network) -
737
+ # without a PING during the wait, the ESP32's 30s silence watchdog
738
+ # flips the OLED to "disconnected" while the host is still alive and
739
+ # just blocked on subprocess.run. Same keepalive download() uses.
740
+ stop_ping, pt = _start_keepalive(ser) if ser else (None, None)
741
+ try:
742
+ r = subprocess.run(
743
+ [*ytdlp_base_args(player_client), '--dump-single-json', '--skip-download', source],
744
+ capture_output=True,
745
+ text=True,
746
+ timeout=90,
747
+ )
748
+ except subprocess.TimeoutExpired:
749
+ errors.append(f"{player_client or 'default'} client timed out")
750
+ continue
751
+ finally:
752
+ if stop_ping:
753
+ stop_ping.set()
754
+ pt.join(timeout=1)
739
755
  if r.returncode != 0:
740
756
  errors.append(r.stderr.strip() or f"{player_client or 'default'} client failed")
741
757
  continue
@@ -1099,7 +1115,11 @@ def author(ser, mpg, job_dir, aspect="4:3"):
1099
1115
  send(ser, "PROGRESS:Building IFO/VOB")
1100
1116
  dvd_dir = job_dir / "dvd_out"
1101
1117
  dvd_dir.mkdir(parents=True, exist_ok=True)
1102
- xml = f"""<dvdauthor dest={chr(34) + str(dvd_dir) + chr(34)} format="pal">
1118
+ # Format is conveyed via the VIDEO_FORMAT env var below, not this XML
1119
+ # attribute - some dvdauthor builds (the Windows one) reject a `format`
1120
+ # attribute on <dvdauthor> outright ("Cannot match attribute 'format'"),
1121
+ # which would fail authoring on every single burn.
1122
+ xml = f"""<dvdauthor dest={chr(34) + str(dvd_dir) + chr(34)}>
1103
1123
  <vmgm />
1104
1124
  <titleset>
1105
1125
  <titles>
@@ -1323,6 +1343,14 @@ def burn(ser, dvd_dir, disc_label, speed=None, is_dual_layer=False):
1323
1343
  try:
1324
1344
  discstation_host.build_data_image([dvd_dir], image_path, disc_label, video=True)
1325
1345
  burn_iso(ser, image_path, speed, is_dual_layer)
1346
+ except (RuntimeError, FileNotFoundError) as e:
1347
+ if discstation_host.system_name() != "windows":
1348
+ raise
1349
+ # no xorriso -> burn the VIDEO_TS tree as a plain data disc (plays on
1350
+ # modern players; not guaranteed on old set-tops).
1351
+ print(f"xorriso unavailable ({e}); burning VIDEO_TS as a data disc")
1352
+ _run_windows_burn(ser, "burn-data.ps1", disc_device(), str(dvd_dir),
1353
+ disc_label, re.sub(r"\D", "", speed or ""))
1326
1354
  finally:
1327
1355
  image_path.unlink(missing_ok=True)
1328
1356
  return
@@ -1350,6 +1378,13 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
1350
1378
  try:
1351
1379
  discstation_host.build_data_image(source_paths, image_path, disc_label)
1352
1380
  burn_iso(ser, image_path, speed, is_dual_layer)
1381
+ except (RuntimeError, FileNotFoundError) as e:
1382
+ if discstation_host.system_name() != "windows":
1383
+ raise
1384
+ print(f"xorriso unavailable ({e}); using IMAPI2 data burn")
1385
+ src = str(source_paths[0]) if len(source_paths) == 1 else _stage_dir(source_paths)
1386
+ _run_windows_burn(ser, "burn-data.ps1", disc_device(), src, disc_label,
1387
+ re.sub(r"\D", "", speed or ""))
1353
1388
  finally:
1354
1389
  image_path.unlink(missing_ok=True)
1355
1390
  return
@@ -1464,6 +1499,17 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1464
1499
  f"{len(track_meta)} track titles")
1465
1500
  send(ser, "PROGRESS:35%")
1466
1501
 
1502
+ if discstation_host.system_name() == "windows":
1503
+ # No cdrdao on Windows — burn the prepared WAVs via IMAPI2 Track-At-Once.
1504
+ send(ser, "STATUS:Burning audio CD...")
1505
+ _run_windows_burn(ser, "burn-audio.ps1", disc_device(), str(tmp_dir),
1506
+ re.sub(r"\D", "", (speed or DISC_SPEED) or ""))
1507
+ for w in tmp_dir.glob("*.wav"):
1508
+ w.unlink(missing_ok=True)
1509
+ toc_path.unlink(missing_ok=True)
1510
+ safe_send(ser, "DONE:Audio CD complete!")
1511
+ return
1512
+
1467
1513
  send(ser, "STATUS:Burning audio CD...")
1468
1514
  # The cooked generic-mmc writer does NOT lay down the CD-TEXT lead-in on most
1469
1515
  # ATAPI drives; the raw writer does. Override with DISCSTATION_CDRDAO_DRIVER
@@ -1524,11 +1570,124 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
1524
1570
  print(f"CD eject skipped: {e}")
1525
1571
 
1526
1572
 
1573
+ def _stage_dir(paths):
1574
+ """Copy several loose paths into one temp folder (IMAPI2 burn-data takes one)."""
1575
+ staging = WORK / f"stage_{time.strftime('%Y%m%d_%H%M%S')}"
1576
+ staging.mkdir(parents=True, exist_ok=True)
1577
+ for p in paths:
1578
+ p = Path(p)
1579
+ dest = staging / p.name
1580
+ if p.is_dir():
1581
+ shutil.copytree(p, dest, dirs_exist_ok=True)
1582
+ else:
1583
+ shutil.copy2(p, dest)
1584
+ return str(staging)
1585
+
1586
+
1587
+ def _estimate_burn_seconds(source):
1588
+ """Rough total write time for the synthetic progress fallback below,
1589
+ assuming a ~4x DVD write speed (5.54MB/s) - real speed varies by drive
1590
+ and media, so this is a ceiling to tune if it drifts, not a promise."""
1591
+ p = Path(source)
1592
+ size = tree_size(p) if p.is_dir() else (p.stat().st_size if p.is_file() else 0)
1593
+ return max(size / (5.54 * 1024 * 1024), 1.0)
1594
+
1595
+
1596
+ def _run_windows_burn(ser, script, *script_args):
1597
+ """Run a src/win/<script> IMAPI2 burn helper, streaming its PROGRESS:<pct>
1598
+ lines to the ESP32. Raises RuntimeError on a non-zero exit.
1599
+
1600
+ IMAPI2's progress event doesn't reliably reach PowerShell on every
1601
+ setup (COM event dispatch needs the calling thread to pump messages,
1602
+ which it can't do while blocked inside the synchronous native Write()
1603
+ call) - so real PROGRESS lines may never arrive until the very end.
1604
+ While waiting, synthesize a smoothly-climbing estimate from elapsed
1605
+ time vs. the source size at a conservative write speed, capped at 95%
1606
+ until the process actually exits; real PROGRESS lines (from scripts
1607
+ where the event does work, e.g. burn-audio.ps1's per-track updates)
1608
+ still take priority whenever they show up.
1609
+ """
1610
+ send(ser, "STATUS:Burning...")
1611
+ send(ser, "PROGRESS:0%")
1612
+ est_total = _estimate_burn_seconds(script_args[1]) if len(script_args) > 1 else None
1613
+ cmd, kwargs = discstation_host.ps_cmd(script, *script_args)
1614
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
1615
+
1616
+ lines = Queue()
1617
+ finished = object()
1618
+
1619
+ def read_output():
1620
+ try:
1621
+ for line in proc.stdout:
1622
+ lines.put(line.rstrip("\r\n"))
1623
+ finally:
1624
+ lines.put(finished)
1625
+
1626
+ reader = threading.Thread(target=read_output, daemon=True)
1627
+ reader.start()
1628
+
1629
+ out_lines, last_pct, last_real_progress, start = [], -1, 0.0, time.time()
1630
+ last_ping, output_done = time.time(), False
1631
+ try:
1632
+ while proc.poll() is None or not output_done:
1633
+ if time.time() - last_ping >= 5:
1634
+ last_ping = time.time()
1635
+ send(ser, "PING")
1636
+ if check_cancel(ser):
1637
+ stop_process(proc)
1638
+ return
1639
+ try:
1640
+ line = lines.get(timeout=0.2)
1641
+ except Empty:
1642
+ line = None
1643
+ if line is finished:
1644
+ output_done = True
1645
+ elif line is not None:
1646
+ out_lines.append(line)
1647
+ m = re.search(r"PROGRESS:(-?\d+)", line)
1648
+ if m:
1649
+ pct = int(m.group(1))
1650
+ if 0 <= pct <= 100 and pct != last_pct:
1651
+ last_pct, last_real_progress = pct, time.time()
1652
+ send(ser, f"PROGRESS:{min(pct, 99)}%")
1653
+ continue
1654
+ # No fresh real progress recently - IMAPI2's Write() blocks the
1655
+ # PowerShell thread the whole time, so its Update event often
1656
+ # never gets dispatched until the call returns. Estimate from
1657
+ # elapsed time instead of leaving the OLED stuck at 0%.
1658
+ if est_total and time.time() - last_real_progress > 3:
1659
+ est_pct = int(min(95, (time.time() - start) / est_total * 100))
1660
+ if est_pct > last_pct:
1661
+ last_pct = est_pct
1662
+ send(ser, f"PROGRESS:{est_pct}%")
1663
+ reader.join(timeout=1)
1664
+ except (KeyboardInterrupt, SystemExit):
1665
+ stop_process(proc)
1666
+ raise
1667
+ if proc.wait() != 0:
1668
+ detail = next((l for l in reversed(out_lines) if l.strip()), "burn failed")
1669
+ raise RuntimeError(f"Disc burn failed: {detail[:150]}")
1670
+ safe_send(ser, "PROGRESS:100%")
1671
+
1672
+
1527
1673
  def burn_iso(ser, iso_path, speed=None, is_dual_layer=False):
1528
1674
  """Burn a pre-built ISO directly to disc — no filesystem building."""
1529
1675
  if discstation_host.system_name() == "darwin":
1530
1676
  _run_hdiutil_burn(ser, iso_path)
1531
1677
  return
1678
+ if discstation_host.system_name() == "windows":
1679
+ drive = disc_device()
1680
+ spd = re.sub(r"\D", "", speed or "")
1681
+ try:
1682
+ _run_windows_burn(ser, "burn-image.ps1", drive, str(iso_path), spd)
1683
+ except RuntimeError:
1684
+ isoburn = shutil.which("isoburn") or os.path.join(
1685
+ os.environ.get("SystemRoot", r"C:\Windows"), "System32", "isoburn.exe")
1686
+ send(ser, "STATUS:Burning image (isoburn)...")
1687
+ if subprocess.run([isoburn, "/Q", drive, str(iso_path)]).returncode != 0:
1688
+ raise
1689
+ safe_send(ser, "PROGRESS:100%")
1690
+ return
1532
1691
  if discstation_host.system_name() != "linux":
1533
1692
  send(ser, "STATUS:Burning image...")
1534
1693
  _run_growisofs(ser, discstation_host.iso_burn_command(disc_device(), iso_path), iso_path.parent / "discstation-burn.log")
@@ -1636,7 +1795,7 @@ def main():
1636
1795
  print("Connected to DiscStation")
1637
1796
  print("Running preflight...")
1638
1797
  send(ser, "STATUS:Preflight...")
1639
- info = get_video_info(url)
1798
+ info = get_video_info(url, ser)
1640
1799
  title = info["title"]
1641
1800
  duration = info["duration"]
1642
1801
  duration_line, fit_line, can_fit = preflight_lines(duration)
@@ -9,6 +9,8 @@ import platform
9
9
  import re
10
10
  import shutil
11
11
  import subprocess
12
+ import sys
13
+ import threading
12
14
  from pathlib import Path
13
15
 
14
16
  from serial.tools import list_ports
@@ -98,6 +100,64 @@ def _mac_optical_device():
98
100
 
99
101
  _last_disc_device = None
100
102
 
103
+ # --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
104
+ _WIN_DIR = Path(__file__).resolve().parent / "win"
105
+ _win_info_cache = (0.0, None)
106
+ _win_info_lock = threading.Lock()
107
+
108
+
109
+ def ps_cmd(script_name, *args):
110
+ """Build a `powershell -File src/win/<script_name> <args>` argv, plus the
111
+ Popen/run kwargs that suppress the console window. The host runs as
112
+ pythonw.exe (no console); without CREATE_NO_WINDOW, Windows pops a
113
+ brand-new visible console for every one of these - and disc detection
114
+ polls every ~1.5s, so it would flash constantly."""
115
+ cmd = ["powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
116
+ "-File", str(_WIN_DIR / script_name), *[str(a) for a in args]]
117
+ kwargs = {"creationflags": subprocess.CREATE_NO_WINDOW} if hasattr(subprocess, "CREATE_NO_WINDOW") else {}
118
+ return cmd, kwargs
119
+
120
+
121
+ def _run_ps(script_name, *args, timeout=25):
122
+ """Run src/win/<script_name> and return (returncode, stdout, stderr)."""
123
+ cmd, kwargs = ps_cmd(script_name, *args)
124
+ try:
125
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)
126
+ return r.returncode, r.stdout, r.stderr
127
+ except (OSError, subprocess.TimeoutExpired):
128
+ return 1, "", ""
129
+
130
+
131
+ def _win_disc_info(force=False):
132
+ """Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure.
133
+
134
+ Locked: disc-info.ps1's IMAPI2 calls appear to serialize on the physical
135
+ drive, so a burst of concurrent callers (multiple web UI tabs, the HTTP
136
+ /disc-info endpoint racing station_loop's own poll) each missing the
137
+ cache at once used to spawn a pile of concurrent powershell.exe
138
+ processes that queued up behind each other and blew past the timeout -
139
+ reporting a false "no disc". One in-flight refresh, shared by whoever's
140
+ waiting, instead of one per caller."""
141
+ import json as _json
142
+ import time
143
+ global _win_info_cache
144
+ with _win_info_lock:
145
+ ts, cached = _win_info_cache
146
+ if not force and cached is not None and time.time() - ts < 2.0:
147
+ return cached
148
+ override = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE") or ""
149
+ rc, out, _ = _run_ps("disc-info.ps1", *([override] if override else []), timeout=20)
150
+ info = {}
151
+ for line in out.splitlines():
152
+ line = line.strip()
153
+ if line.startswith("{"):
154
+ try:
155
+ info = _json.loads(line)
156
+ except ValueError:
157
+ pass
158
+ _win_info_cache = (time.time(), info)
159
+ return info
160
+
101
161
 
102
162
  def disc_device():
103
163
  global _last_disc_device
@@ -140,13 +200,22 @@ def disc_device():
140
200
  if "/dev/disk" in line and ("CD" in line or "DVD" in line or "optical" in line.lower()):
141
201
  return line.strip().split()[0]
142
202
  elif system == "windows":
143
- raise RuntimeError("Set DISC_DEVICE to the optical drive letter on Windows")
203
+ info = _win_disc_info()
204
+ if info.get("drive"):
205
+ return info["drive"]
206
+ if override:
207
+ return override
144
208
  raise FileNotFoundError("No optical disc drive found; set DISC_DEVICE explicitly")
145
209
 
146
210
 
147
211
  def drive_status():
148
212
  """Non-Linux equivalent of the CDROM_DRIVE_STATUS ioctl.
149
- Returns 'disc' | 'no_disc' | 'unknown'. macOS: parse `drutil status`."""
213
+ Returns 'disc' | 'no_disc' | 'unknown'. macOS: `drutil status`; Windows: IMAPI2/WMI."""
214
+ if system_name() == "windows":
215
+ info = _win_disc_info()
216
+ if not info:
217
+ return "unknown"
218
+ return "disc" if info.get("media_loaded") else "no_disc"
150
219
  if system_name() != "darwin":
151
220
  return "unknown"
152
221
  try:
@@ -244,10 +313,35 @@ def media_properties(device):
244
313
  props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
245
314
  _tag_rewritable(props, optical)
246
315
  return props
316
+ if system_name() == "windows":
317
+ info = _win_disc_info()
318
+ if not info.get("media_loaded"):
319
+ return {}
320
+ props = {"ID_CDROM": "1", "ID_CDROM_MEDIA": "1"}
321
+ if info.get("label"):
322
+ props["ID_FS_LABEL"] = info["label"]
323
+ if info.get("fs") in ("udf", "iso9660"):
324
+ props["ID_FS_TYPE"] = info["fs"]
325
+ mtype = (info.get("media_type") or "").lower()
326
+ if mtype == "audio_cd" or (not info.get("fs") and not info.get("blank") and mtype.startswith("cd")):
327
+ props["ID_CDROM_MEDIA_TYPE"] = "audio"
328
+ elif mtype.startswith("dvd") or mtype.startswith("bd"):
329
+ props["ID_CDROM_MEDIA_TYPE"] = "dvd"
330
+ if info.get("blank"):
331
+ props["ID_CDROM_MEDIA_STATE"] = "blank"
332
+ if "dvd+r dl" in mtype or "dvd-r dl" in mtype:
333
+ props["ID_CDROM_MEDIA_DVD_PLUS_R_DL"] = "1"
334
+ elif ("dvd+r" in mtype or "dvd-r" in mtype) and "rw" not in mtype:
335
+ props["ID_CDROM_MEDIA_DVD_PLUS_R"] = "1"
336
+ if info.get("rewritable"):
337
+ _tag_rewritable(props, mtype)
338
+ return props
247
339
  return {}
248
340
 
249
341
 
250
342
  def media_capacity_bytes(device):
343
+ if system_name() == "windows":
344
+ return _win_disc_info().get("capacity_bytes") or None
251
345
  if system_name() == "darwin":
252
346
  try:
253
347
  result = subprocess.run(["/usr/sbin/diskutil", "info", device], capture_output=True, text=True, check=False, timeout=3)
@@ -267,9 +361,28 @@ def tool(name):
267
361
  local = user_home() / ".local" / "bin" / name
268
362
  if local.exists():
269
363
  return str(local)
364
+ # pip-installed console scripts (yt-dlp, etc. from requirements.txt) land
365
+ # in the venv's own Scripts/bin dir, which isn't necessarily on PATH -
366
+ # e.g. the Windows Scheduled Task runs pythonw.exe directly, no shell
367
+ # activation. sys.exec_prefix is the venv root we're actually running in.
368
+ venv_bin = Path(sys.exec_prefix) / ("Scripts" if system_name() == "windows" else "bin")
369
+ for candidate_name in ((name + ".exe", name) if system_name() == "windows" else (name,)):
370
+ candidate = venv_bin / candidate_name
371
+ if candidate.exists():
372
+ return str(candidate)
270
373
  path = shutil.which(name)
271
374
  if path:
272
375
  return path
376
+ if system_name() == "windows":
377
+ # install-windows.ps1's no-winget fallback downloads ffmpeg/yt-dlp
378
+ # into config_dir()/tools (e.g. an extracted "ffmpeg-9.0-essentials
379
+ # build\bin\"), and only puts it on the *installer script's own*
380
+ # session PATH - gone the moment the installer exits. Search that
381
+ # tree directly instead of relying on PATH.
382
+ exe = name if name.lower().endswith(".exe") else name + ".exe"
383
+ match = next((config_dir() / "tools").rglob(exe), None) if (config_dir() / "tools").exists() else None
384
+ if match:
385
+ return str(match)
273
386
  if system_name() == "darwin":
274
387
  candidates = [
275
388
  Path("/opt/homebrew/bin") / name,
@@ -451,6 +564,12 @@ def eject_device(device, close=False):
451
564
  except (OSError, subprocess.TimeoutExpired):
452
565
  pass
453
566
  return False
567
+ elif system == "windows":
568
+ args = [device] if device else []
569
+ if close:
570
+ args.append("-Close")
571
+ rc, _, _ = _run_ps("eject.ps1", *args, timeout=20)
572
+ return rc == 0
454
573
  else:
455
- raise RuntimeError("Automatic optical-drive eject is not implemented on Windows")
574
+ raise RuntimeError("Automatic optical-drive eject is not implemented on this OS")
456
575
  return subprocess.run(command, capture_output=True, text=True, timeout=10).returncode == 0
package/src/static/app.js CHANGED
@@ -66,6 +66,7 @@
66
66
  setConnection(true);
67
67
  setLiveStatus(progress.status);
68
68
  setProgress(progress.status, Number(progress.progress), progress.active);
69
+ applyRemoteState(progress);
69
70
  } catch (_) {
70
71
  setConnection(false);
71
72
  setLiveStatus("OFFLINE");
@@ -73,10 +74,67 @@
73
74
  }
74
75
  }
75
76
 
77
+ // --- On-screen remote: the exact same text commands the ESP32 sends -----
78
+ // POSTed to /remote/button, which feeds a VirtualSerial standing in for a
79
+ // real appliance. Only usable when no hardware remote is attached.
80
+ const remoteKey = "discstation-remote-visible";
81
+
82
+ function setRemoteVisible(visible, persist = false) {
83
+ $("remote-panel").hidden = !visible;
84
+ $("brand-link").classList.toggle("remote-active", visible);
85
+ if (persist) localStorage.setItem(remoteKey, visible ? "1" : "0");
86
+ }
87
+
88
+ function toggleRemotePanel() {
89
+ setRemoteVisible($("remote-panel").hidden, true);
90
+ }
91
+
92
+ function applyRemoteState(progress) {
93
+ const hardware = progress.appliance === "hardware";
94
+ if (hardware && !$("remote-panel").hidden) setRemoteVisible(false, true);
95
+ $("remote-note").textContent = hardware
96
+ ? "A physical remote is attached — on-screen controls are disabled."
97
+ : "No physical remote detected — control DiscStation from here.";
98
+ $("remote-controls").querySelectorAll("button, input").forEach((el) => { el.disabled = hardware; });
99
+ $("remote-transport").hidden = !progress.playing;
100
+ const ejectBtn = $("remote-eject-btn");
101
+ const open = !!progress.tray_open;
102
+ ejectBtn.textContent = open ? "CLOSE TRAY" : "EJECT";
103
+ ejectBtn.dataset.cmd = open ? "CONFIRM" : "EJECT";
104
+ }
105
+
106
+ async function sendRemoteCmd(cmd) {
107
+ try {
108
+ await fetch("/remote/button", {
109
+ method: "POST",
110
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
111
+ body: new URLSearchParams({ cmd })
112
+ });
113
+ } catch (_) { /* next poll/SSE tick reflects reality */ }
114
+ }
115
+
116
+ $("brand-link").addEventListener("click", (event) => {
117
+ event.preventDefault();
118
+ toggleRemotePanel();
119
+ });
120
+ setRemoteVisible(localStorage.getItem(remoteKey) === "1");
121
+
122
+ $("remote-controls").addEventListener("click", (event) => {
123
+ const button = event.target.closest("[data-cmd]");
124
+ if (button && !button.disabled) sendRemoteCmd(button.dataset.cmd);
125
+ });
126
+ let volumeTimer;
127
+ $("remote-volume").addEventListener("input", (event) => {
128
+ clearTimeout(volumeTimer);
129
+ const value = event.target.value;
130
+ volumeTimer = setTimeout(() => sendRemoteCmd(`POT:${value}`), 150);
131
+ });
132
+
76
133
  async function loadDiscInfo() {
77
134
  try {
78
135
  const response = await fetch("/disc-info", { cache: "no-store" });
79
136
  const info = await response.json();
137
+ renderDiscStatus(info);
80
138
  if (info.busy) return; // burn/rip in progress — keep current
81
139
  state.discBytes = Number(info.capacity_bytes || 0);
82
140
  state.discType = info.type || "none";
@@ -84,9 +142,22 @@
84
142
  } catch (_) {
85
143
  state.discBytes = 0;
86
144
  state.discType = "none";
145
+ renderDiscStatus(null);
87
146
  }
88
147
  }
89
148
 
149
+ function renderDiscStatus(info) {
150
+ const el = $("remote-disc-status");
151
+ if (!el) return;
152
+ if (!info) { el.textContent = "DISC: UNKNOWN"; return; }
153
+ if (info.busy) { el.textContent = "DISC: BUSY (see status above)"; return; }
154
+ if (!info.disc_present) { el.textContent = "DISC: NONE"; return; }
155
+ const kind = (info.type || info.kind || "unknown").toUpperCase();
156
+ const label = info.label ? ` "${info.label}"` : "";
157
+ const size = info.capacity_gb ? ` // ${info.capacity_gb}GB` : "";
158
+ el.textContent = `DISC: ${kind}${label}${size}`;
159
+ }
160
+
90
161
  function renderSelection() {
91
162
  const list = $("selection-list");
92
163
  const total = state.entries.reduce((sum, entry) => sum + entry.file.size, 0);
@@ -269,6 +340,7 @@
269
340
  setConnection(true);
270
341
  setLiveStatus(d.status);
271
342
  setProgress(d.status, Number(d.progress), d.active);
343
+ applyRemoteState(d);
272
344
  });
273
345
  es.addEventListener("open", () => { setConnection(true); loadDiscInfo(); });
274
346
  es.addEventListener("error", () => {
@@ -13,9 +13,10 @@
13
13
  <body>
14
14
  <div class="page-shell">
15
15
  <header class="topbar">
16
- <a class="brand" href="/" aria-label="DiscStation home">
16
+ <a class="brand" href="/" id="brand-link" aria-label="DiscStation home / toggle remote">
17
17
  <span class="brand-mark">DS</span>
18
18
  <span>DISCSTATION</span>
19
+ <span id="remote-hint" class="remote-hint">REMOTE</span>
19
20
  </a>
20
21
  <div class="topbar-actions">
21
22
  <button id="theme-toggle" class="theme-toggle" type="button" aria-label="Switch to dark mode">&#x263E;</button>
@@ -34,6 +35,39 @@
34
35
  <div><strong>04</strong><span>RIP / PLAY</span></div>
35
36
  </section>
36
37
 
38
+ <section id="remote-panel" class="remote-panel section-rule" hidden aria-labelledby="remote-title">
39
+ <div class="panel-heading">
40
+ <div>
41
+ <div class="section-kicker">ON-SCREEN REMOTE</div>
42
+ <h2 id="remote-title">CONTROL SURFACE</h2>
43
+ </div>
44
+ <span class="panel-index">DISCSTN-02</span>
45
+ </div>
46
+ <p id="remote-note" class="field-note"></p>
47
+ <div id="remote-disc-status" class="remote-disc-status">DISC: CHECKING</div>
48
+ <div id="remote-controls">
49
+ <div class="remote-grid">
50
+ <button class="outline-button" type="button" data-cmd="SELECT:BURN">BURN</button>
51
+ <button class="outline-button" type="button" data-cmd="SELECT:BURN DATA">BURN DATA</button>
52
+ <button class="outline-button" type="button" data-cmd="SELECT:BURN AUDIO">BURN AUDIO</button>
53
+ <button class="outline-button" type="button" data-cmd="SELECT:RIP">RIP</button>
54
+ <button class="outline-button" type="button" data-cmd="SELECT:PLAY">PLAY</button>
55
+ <button class="outline-button" type="button" data-cmd="CANCEL">CANCEL / HOME</button>
56
+ <button class="outline-button" type="button" id="remote-eject-btn" data-cmd="EJECT">EJECT</button>
57
+ </div>
58
+ <div id="remote-transport" class="remote-transport" hidden>
59
+ <div class="remote-grid">
60
+ <button class="outline-button" type="button" data-cmd="REW:BIG">&#x23EE; PREV</button>
61
+ <button class="outline-button" type="button" data-cmd="PLAY_BUTTON">&#x23EF; PLAY/PAUSE</button>
62
+ <button class="outline-button" type="button" data-cmd="FF:BIG">&#x23ED; NEXT</button>
63
+ <button class="outline-button" type="button" data-cmd="PLAY_STOP">&#x23F9; STOP</button>
64
+ </div>
65
+ <label class="field-label" for="remote-volume">VOLUME</label>
66
+ <input id="remote-volume" type="range" min="0" max="100" value="70">
67
+ </div>
68
+ </div>
69
+ </section>
70
+
37
71
  <section class="burn-panel section-rule" aria-labelledby="burn-title">
38
72
  <div class="panel-heading">
39
73
  <div>
@@ -105,7 +139,7 @@
105
139
  </footer>
106
140
  </div>
107
141
  <div id="install-slot"></div>
108
- <script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=8").catch(() => {});</script>
109
- <script src="/static/app.js?v=8" defer></script>
142
+ <script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=17").catch(() => {});</script>
143
+ <script src="/static/app.js?v=17" defer></script>
110
144
  </body>
111
145
  </html>
@@ -99,6 +99,27 @@ button { color: inherit; }
99
99
  color: var(--ink);
100
100
  text-decoration: none;
101
101
  font-weight: 700;
102
+ cursor: pointer;
103
+ }
104
+
105
+ .remote-hint {
106
+ display: inline-flex;
107
+ align-items: center;
108
+ height: 16px;
109
+ padding: 0 6px;
110
+ border: 1px solid var(--soft-line);
111
+ color: var(--muted);
112
+ font-size: 8px;
113
+ font-weight: 700;
114
+ letter-spacing: 0.08em;
115
+ white-space: nowrap;
116
+ text-shadow: none;
117
+ transition: color 0.2s ease, border-color 0.2s ease, text-shadow 0.2s ease;
118
+ }
119
+ .brand.remote-active .remote-hint {
120
+ color: var(--ink);
121
+ border-color: var(--ink);
122
+ text-shadow: 0 0 6px var(--accent);
102
123
  }
103
124
 
104
125
  .brand-mark {
@@ -278,6 +299,21 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
278
299
  .form-message.error { color: #9d4035; }
279
300
  .form-message.ok { color: #287346; }
280
301
 
302
+ .remote-panel { padding: 26px 0 30px; }
303
+ .remote-disc-status {
304
+ margin: 10px 0 4px;
305
+ padding: 10px 12px;
306
+ border: 1px solid var(--ink);
307
+ background: var(--surface);
308
+ font-size: 11px;
309
+ font-weight: 700;
310
+ letter-spacing: 0.06em;
311
+ }
312
+ .remote-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 16px; }
313
+ #remote-controls button:disabled, #remote-controls input:disabled { cursor: not-allowed; opacity: 0.4; }
314
+ .remote-transport { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--soft-line); }
315
+ .remote-transport input[type="range"] { width: 100%; margin-top: 4px; accent-color: var(--accent); }
316
+
281
317
  .footer-stamp {
282
318
  display: flex;
283
319
  justify-content: space-between;
@@ -315,6 +351,8 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
315
351
  .capability-strip div:not(:last-child) { border-right: 1px solid var(--soft-line); }
316
352
  .capability-strip div + div { padding-left: 18px; }
317
353
  .burn-panel { width: min(100%, 720px); margin-left: auto; margin-right: auto; }
354
+ .remote-panel { width: min(100%, 720px); margin-left: auto; margin-right: auto; }
355
+ .remote-panel .remote-grid { grid-template-columns: repeat(3, 1fr); }
318
356
  }
319
357
 
320
358
  @media (max-width: 600px) {
@@ -0,0 +1,29 @@
1
+ # Minimal JSON emitter - works on PowerShell 2.0 (Win7) and up.
2
+ function ConvertTo-JsonCompat {
3
+ param([Parameter(ValueFromPipeline = $true)] $obj)
4
+ if ($null -eq $obj) { return 'null' }
5
+ switch ($obj.GetType().Name) {
6
+ 'Boolean' { return $obj.ToString().ToLower() }
7
+ 'Int32' { return $obj.ToString() }
8
+ 'Int64' { return $obj.ToString() }
9
+ 'Double' { return $obj.ToString([System.Globalization.CultureInfo]::InvariantCulture) }
10
+ 'String' {
11
+ $s = $obj -replace '\\', '\\' -replace '"', '\"' -replace "`r", '\r' -replace "`n", '\n' -replace "`t", '\t'
12
+ return '"' + $s + '"'
13
+ }
14
+ 'Hashtable' {
15
+ $parts = @()
16
+ foreach ($k in $obj.Keys) { $parts += ('"' + $k + '":' + (ConvertTo-JsonCompat $obj[$k])) }
17
+ return '{' + ($parts -join ',') + '}'
18
+ }
19
+ 'Object[]' {
20
+ $parts = @()
21
+ foreach ($v in $obj) { $parts += (ConvertTo-JsonCompat $v) }
22
+ return '[' + ($parts -join ',') + ']'
23
+ }
24
+ default {
25
+ $s = "$obj" -replace '\\', '\\' -replace '"', '\"'
26
+ return '"' + $s + '"'
27
+ }
28
+ }
29
+ }