discstation 0.1.20 → 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.
- package/arduino/c6/DiscStation_C6.ino +7 -1
- package/arduino/v1/DiscStation.ino +4 -1
- package/install-windows.ps1 +29 -6
- package/package.json +1 -1
- package/src/discstation.py +409 -83
- package/src/discstation_burn.py +96 -18
- package/src/discstation_host.py +47 -16
- package/src/static/app.js +72 -0
- package/src/static/index.html +37 -3
- package/src/static/style.css +38 -0
- package/src/win/burn-audio.ps1 +18 -3
- package/src/win/burn-data.ps1 +21 -9
- package/src/win/burn-image.ps1 +17 -13
- package/src/win/disc-info.ps1 +13 -8
- package/src/win/play-audio-cd.ps1 +94 -0
package/src/discstation_burn.py
CHANGED
|
@@ -721,7 +721,7 @@ def get_local_video_info(path):
|
|
|
721
721
|
return {"title": stem, "duration": dur}
|
|
722
722
|
|
|
723
723
|
|
|
724
|
-
def get_video_info(source):
|
|
724
|
+
def get_video_info(source, ser=None):
|
|
725
725
|
p = Path(source)
|
|
726
726
|
if p.is_dir():
|
|
727
727
|
videos = find_video_files(source)
|
|
@@ -733,11 +733,25 @@ def get_video_info(source):
|
|
|
733
733
|
return get_local_video_info(source)
|
|
734
734
|
errors = []
|
|
735
735
|
for player_client in YTDLP_PLAYER_CLIENTS or (None,):
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
)
|
|
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)
|
|
741
755
|
if r.returncode != 0:
|
|
742
756
|
errors.append(r.stderr.strip() or f"{player_client or 'default'} client failed")
|
|
743
757
|
continue
|
|
@@ -1101,7 +1115,11 @@ def author(ser, mpg, job_dir, aspect="4:3"):
|
|
|
1101
1115
|
send(ser, "PROGRESS:Building IFO/VOB")
|
|
1102
1116
|
dvd_dir = job_dir / "dvd_out"
|
|
1103
1117
|
dvd_dir.mkdir(parents=True, exist_ok=True)
|
|
1104
|
-
|
|
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)}>
|
|
1105
1123
|
<vmgm />
|
|
1106
1124
|
<titleset>
|
|
1107
1125
|
<titles>
|
|
@@ -1566,23 +1584,83 @@ def _stage_dir(paths):
|
|
|
1566
1584
|
return str(staging)
|
|
1567
1585
|
|
|
1568
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
|
+
|
|
1569
1596
|
def _run_windows_burn(ser, script, *script_args):
|
|
1570
1597
|
"""Run a src/win/<script> IMAPI2 burn helper, streaming its PROGRESS:<pct>
|
|
1571
|
-
lines to the ESP32. Raises RuntimeError on a non-zero exit.
|
|
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
|
+
"""
|
|
1572
1610
|
send(ser, "STATUS:Burning...")
|
|
1573
1611
|
send(ser, "PROGRESS:0%")
|
|
1612
|
+
est_total = _estimate_burn_seconds(script_args[1]) if len(script_args) > 1 else None
|
|
1574
1613
|
cmd, kwargs = discstation_host.ps_cmd(script, *script_args)
|
|
1575
1614
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **kwargs)
|
|
1576
|
-
|
|
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
|
|
1577
1631
|
try:
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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)
|
|
1586
1664
|
except (KeyboardInterrupt, SystemExit):
|
|
1587
1665
|
stop_process(proc)
|
|
1588
1666
|
raise
|
|
@@ -1717,7 +1795,7 @@ def main():
|
|
|
1717
1795
|
print("Connected to DiscStation")
|
|
1718
1796
|
print("Running preflight...")
|
|
1719
1797
|
send(ser, "STATUS:Preflight...")
|
|
1720
|
-
info = get_video_info(url)
|
|
1798
|
+
info = get_video_info(url, ser)
|
|
1721
1799
|
title = info["title"]
|
|
1722
1800
|
duration = info["duration"]
|
|
1723
1801
|
duration_line, fit_line, can_fit = preflight_lines(duration)
|
package/src/discstation_host.py
CHANGED
|
@@ -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
|
|
@@ -101,6 +103,7 @@ _last_disc_device = None
|
|
|
101
103
|
# --- Windows: IMAPI2 / WMI probes via bundled PowerShell helpers ---------------
|
|
102
104
|
_WIN_DIR = Path(__file__).resolve().parent / "win"
|
|
103
105
|
_win_info_cache = (0.0, None)
|
|
106
|
+
_win_info_lock = threading.Lock()
|
|
104
107
|
|
|
105
108
|
|
|
106
109
|
def ps_cmd(script_name, *args):
|
|
@@ -126,25 +129,34 @@ def _run_ps(script_name, *args, timeout=25):
|
|
|
126
129
|
|
|
127
130
|
|
|
128
131
|
def _win_disc_info(force=False):
|
|
129
|
-
"""Cached (~2s) dict from src/win/disc-info.ps1, or {} on failure.
|
|
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."""
|
|
130
141
|
import json as _json
|
|
131
142
|
import time
|
|
132
143
|
global _win_info_cache
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
line
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
148
160
|
|
|
149
161
|
|
|
150
162
|
def disc_device():
|
|
@@ -349,9 +361,28 @@ def tool(name):
|
|
|
349
361
|
local = user_home() / ".local" / "bin" / name
|
|
350
362
|
if local.exists():
|
|
351
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)
|
|
352
373
|
path = shutil.which(name)
|
|
353
374
|
if path:
|
|
354
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)
|
|
355
386
|
if system_name() == "darwin":
|
|
356
387
|
candidates = [
|
|
357
388
|
Path("/opt/homebrew/bin") / name,
|
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", () => {
|
package/src/static/index.html
CHANGED
|
@@ -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">☾</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">⏮ PREV</button>
|
|
61
|
+
<button class="outline-button" type="button" data-cmd="PLAY_BUTTON">⏯ PLAY/PAUSE</button>
|
|
62
|
+
<button class="outline-button" type="button" data-cmd="FF:BIG">⏭ NEXT</button>
|
|
63
|
+
<button class="outline-button" type="button" data-cmd="PLAY_STOP">⏹ 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=
|
|
109
|
-
<script src="/static/app.js?v=
|
|
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>
|
package/src/static/style.css
CHANGED
|
@@ -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) {
|
package/src/win/burn-audio.ps1
CHANGED
|
@@ -44,14 +44,28 @@ foreach ($w in $wavs) {
|
|
|
44
44
|
$offset = $i + 8; break
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
$
|
|
48
|
-
|
|
47
|
+
$rawLen = $bytes.Length - $offset
|
|
48
|
+
# Red Book requires each track's byte length to be an exact multiple of
|
|
49
|
+
# the 2352-byte CD-DA sector - real-world track lengths essentially
|
|
50
|
+
# never land on that boundary naturally. AddAudioTrack rejects anything
|
|
51
|
+
# else outright ("The provided audio stream is not valid."). Pad with
|
|
52
|
+
# silence up to the next sector boundary (and up to the 4-second/
|
|
53
|
+
# 300-sector minimum a track must have) rather than trim real audio.
|
|
54
|
+
$sectorSize = 2352
|
|
55
|
+
$minLen = 300 * $sectorSize
|
|
56
|
+
$paddedLen = [Math]::Ceiling([Math]::Max($rawLen, $minLen) / $sectorSize) * $sectorSize
|
|
57
|
+
$raw = New-Object byte[] $paddedLen
|
|
58
|
+
[Array]::Copy($bytes, $offset, $raw, 0, $rawLen)
|
|
49
59
|
$prepared += ,@{ name = $w.Name; data = $raw }
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
$total = $prepared.Count
|
|
53
63
|
$done = 0
|
|
54
64
|
try {
|
|
65
|
+
# AddAudioTrack throws E_IMAPI_DF2TAO_MEDIA_IS_NOT_PREPARED ("only valid
|
|
66
|
+
# when media has been prepared") without this - PrepareMedia locks the
|
|
67
|
+
# drive for the write session, ReleaseMedia below hands it back.
|
|
68
|
+
$fmt.PrepareMedia()
|
|
55
69
|
foreach ($t in $prepared) {
|
|
56
70
|
$stream = New-Object -ComObject "ADODB.Stream"
|
|
57
71
|
$stream.Type = 1; $stream.Open()
|
|
@@ -62,11 +76,12 @@ try {
|
|
|
62
76
|
$done++
|
|
63
77
|
Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $total)))
|
|
64
78
|
}
|
|
65
|
-
$fmt.
|
|
79
|
+
$fmt.ReleaseMedia()
|
|
66
80
|
$fmt.Recorder.EjectMedia()
|
|
67
81
|
Write-Output "PROGRESS:100"
|
|
68
82
|
exit 0
|
|
69
83
|
} catch {
|
|
84
|
+
try { $fmt.ReleaseMedia() } catch {}
|
|
70
85
|
Write-Error ("audio burn failed: " + $_.Exception.Message)
|
|
71
86
|
exit 1
|
|
72
87
|
}
|
package/src/win/burn-data.ps1
CHANGED
|
@@ -28,6 +28,9 @@ $fmt = New-Object -ComObject "IMAPI2.MsftDiscFormat2Data"
|
|
|
28
28
|
if (-not $fmt.IsRecorderSupported($rec)) { Write-Error "recorder not supported"; exit 3 }
|
|
29
29
|
$fmt.Recorder = $rec
|
|
30
30
|
$fmt.ClientName = "DiscStation"
|
|
31
|
+
# Without this the disc session never finalizes - the drive reports the
|
|
32
|
+
# disc as still blank afterward even though the data is physically there.
|
|
33
|
+
try { $fmt.ForceMediaToBeClosed = $true } catch {}
|
|
31
34
|
if ($Speed -and $Speed -match '^\d+') {
|
|
32
35
|
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
33
36
|
}
|
|
@@ -41,7 +44,11 @@ $fsi.FreeMediaBlocks = -1 # -1 = use the whole disc
|
|
|
41
44
|
|
|
42
45
|
$item = Get-Item -LiteralPath $Source
|
|
43
46
|
if ($item.PSIsContainer) {
|
|
44
|
-
|
|
47
|
+
# AddTree's 2nd arg is IncludeBaseDirectory: $false flattens a folder
|
|
48
|
+
# child into just its contents at the disc root (dropping the folder
|
|
49
|
+
# name entirely) - wrong for VIDEO_TS/AUDIO_TS or any subfolder, which
|
|
50
|
+
# need to keep their own name. $true preserves it as a real subfolder.
|
|
51
|
+
foreach ($child in Get-ChildItem -LiteralPath $Source) { $fsi.Root.AddTree($child.FullName, $true) }
|
|
45
52
|
} else {
|
|
46
53
|
$fsi.Root.AddTree($item.FullName, $false)
|
|
47
54
|
}
|
|
@@ -49,14 +56,19 @@ if ($item.PSIsContainer) {
|
|
|
49
56
|
$result = $fsi.CreateResultImage()
|
|
50
57
|
$stream = $result.ImageStream
|
|
51
58
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
# Not fatal if registration fails (seen on some setups: "Cannot register
|
|
60
|
+
# for the specified event... does not exist") - the burn itself doesn't
|
|
61
|
+
# need it, just no live PROGRESS lines.
|
|
62
|
+
try {
|
|
63
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
64
|
+
$s = $EventArgs
|
|
65
|
+
try {
|
|
66
|
+
$done = [double]$s.LastWrittenLba
|
|
67
|
+
$tot = [double]$s.SectorCount
|
|
68
|
+
if ($tot -gt 0) { Write-Output ("PROGRESS:" + [int]([math]::Min(99, $done * 100.0 / $tot))) }
|
|
69
|
+
} catch {}
|
|
70
|
+
} | Out-Null
|
|
71
|
+
} catch {}
|
|
60
72
|
|
|
61
73
|
try {
|
|
62
74
|
$fmt.Write($stream)
|
package/src/win/burn-image.ps1
CHANGED
|
@@ -31,19 +31,23 @@ if ($Speed -and $Speed -match '^\d+') {
|
|
|
31
31
|
try { $fmt.SetWriteSpeed([int]($Speed -replace '\D',''), $false) } catch {}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
# Progress: IMAPI2 raises an Update event with sector counts.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
$
|
|
41
|
-
|
|
42
|
-
$
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
34
|
+
# Progress: IMAPI2 raises an Update event with sector counts. Not fatal if
|
|
35
|
+
# registration fails (seen on some setups: "Cannot register for the
|
|
36
|
+
# specified event... does not exist") - the burn itself doesn't need it,
|
|
37
|
+
# just no live PROGRESS lines.
|
|
38
|
+
try {
|
|
39
|
+
Register-ObjectEvent -InputObject $fmt -EventName "Update" -SourceIdentifier "burn" -Action {
|
|
40
|
+
$s = $EventArgs
|
|
41
|
+
try {
|
|
42
|
+
$done = [double]$s.LastWrittenLba
|
|
43
|
+
$tot = [double]$s.SectorCount
|
|
44
|
+
if ($tot -gt 0) {
|
|
45
|
+
$pct = [int]([math]::Min(99, $done * 100.0 / $tot))
|
|
46
|
+
Write-Output "PROGRESS:$pct"
|
|
47
|
+
}
|
|
48
|
+
} catch {}
|
|
49
|
+
} | Out-Null
|
|
50
|
+
} catch {}
|
|
47
51
|
|
|
48
52
|
$stream = New-Object -ComObject "ADODB.Stream"
|
|
49
53
|
$stream.Type = 1 # binary
|
package/src/win/disc-info.ps1
CHANGED
|
@@ -23,11 +23,18 @@ try {
|
|
|
23
23
|
if (-not $out.media_loaded) { Write-Output (ConvertTo-JsonCompat $out); exit 0 }
|
|
24
24
|
|
|
25
25
|
# Volume label + filesystem (WMI logical disk).
|
|
26
|
+
$isAudioCd = $false
|
|
26
27
|
try {
|
|
27
28
|
$ld = Get-WmiObject Win32_LogicalDisk -Filter ("DeviceID='" + $out.drive + "'")
|
|
28
29
|
if ($ld) {
|
|
29
30
|
if ($ld.VolumeName) { $out.label = $ld.VolumeName }
|
|
30
|
-
|
|
31
|
+
# Windows presents a synthetic CDFS view (fixed volume name "Audio
|
|
32
|
+
# CD") for audio discs, purely so Explorer can browse track01.cda
|
|
33
|
+
# files - it's not a real filesystem. Treating it as one made every
|
|
34
|
+
# audio CD misclassify as a data disc.
|
|
35
|
+
if ($ld.VolumeName -eq "Audio CD" -and "$($ld.FileSystem)" -ieq "CDFS") {
|
|
36
|
+
$isAudioCd = $true
|
|
37
|
+
} elseif ($ld.FileSystem) {
|
|
31
38
|
$fs = $ld.FileSystem.ToLower()
|
|
32
39
|
if ($fs -match "udf") { $out.fs = "udf" }
|
|
33
40
|
elseif ($fs -match "cdfs|iso9660") { $out.fs = "iso9660" }
|
|
@@ -64,13 +71,11 @@ try {
|
|
|
64
71
|
}
|
|
65
72
|
} catch {}
|
|
66
73
|
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
$out.media_type = "audio_cd"
|
|
73
|
-
} catch {}
|
|
74
|
+
# $isAudioCd (Windows' own synthetic "Audio CD" CDFS view, detected above) is
|
|
75
|
+
# authoritative - overrides whatever IMAPI2's physical-media-type guess said,
|
|
76
|
+
# since a finalized audio CD-R still reports as generic "cd-rom" there.
|
|
77
|
+
if ($isAudioCd) {
|
|
78
|
+
$out.media_type = "audio_cd"
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
Write-Output (ConvertTo-JsonCompat $out)
|