discstation 0.1.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/LICENSE +21 -0
- package/README.md +137 -0
- package/arduino/c6/DiscStation_C6.ino +914 -0
- package/arduino/v1/DiscStation.ino +957 -0
- package/discstation.env.example +19 -0
- package/docs/PLATFORM_SUPPORT.md +39 -0
- package/install-macos.sh +80 -0
- package/install-windows.ps1 +24 -0
- package/install.sh +56 -0
- package/package.json +48 -0
- package/requirements.txt +20 -0
- package/scripts/setup.mjs +78 -0
- package/src/discstation.py +3984 -0
- package/src/discstation_burn.py +1697 -0
- package/src/discstation_host.py +380 -0
- package/src/discstation_meta.py +150 -0
- package/src/static/app.js +268 -0
- package/src/static/index.html +111 -0
- package/src/static/style.css +328 -0
- package/systemd/discstation.service +13 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const $ = (id) => document.getElementById(id);
|
|
5
|
+
const state = { entries: [], discBytes: 0, discType: "none" };
|
|
6
|
+
const themeKey = "discstation-theme";
|
|
7
|
+
const themeToggle = $("theme-toggle");
|
|
8
|
+
|
|
9
|
+
function applyTheme(theme, persist = false) {
|
|
10
|
+
document.documentElement.dataset.theme = theme;
|
|
11
|
+
themeToggle.textContent = theme === "dark" ? "\u2600" : "\u263E";
|
|
12
|
+
themeToggle.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode");
|
|
13
|
+
if (persist) localStorage.setItem(themeKey, theme);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const savedTheme = localStorage.getItem(themeKey);
|
|
17
|
+
const systemDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
18
|
+
applyTheme(savedTheme || (systemDark ? "dark" : "light"));
|
|
19
|
+
themeToggle.addEventListener("click", () => {
|
|
20
|
+
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true);
|
|
21
|
+
});
|
|
22
|
+
if (!savedTheme && window.matchMedia) {
|
|
23
|
+
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => applyTheme(event.matches ? "dark" : "light"));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const formatSize = (bytes) => {
|
|
27
|
+
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
|
|
28
|
+
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
29
|
+
if (bytes >= 1e3) return `${Math.round(bytes / 1e3)} KB`;
|
|
30
|
+
return `${bytes} B`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, (c) => ({
|
|
34
|
+
"&": "&", "<": "<", ">": ">", "'": "'", '"': """
|
|
35
|
+
})[c]);
|
|
36
|
+
|
|
37
|
+
const rootFor = (path) => path.includes("/") ? path.split("/")[0] : null;
|
|
38
|
+
|
|
39
|
+
function setConnection(online) {
|
|
40
|
+
const dot = $("connection-dot");
|
|
41
|
+
const label = $("connection-label");
|
|
42
|
+
dot.className = `connection-dot ${online ? "online" : "offline"}`;
|
|
43
|
+
label.textContent = online ? "LINK: LIVE" : "LINK: OFFLINE";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setLiveStatus(value) {
|
|
47
|
+
const text = (value || "Idle").trim();
|
|
48
|
+
$("live-status").textContent = text.toUpperCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function setProgress(phase, percent, active = true) {
|
|
52
|
+
const panel = $("global-progress");
|
|
53
|
+
const fill = $("progress-fill");
|
|
54
|
+
panel.hidden = !active && percent < 0;
|
|
55
|
+
$("progress-phase").textContent = (phase || "READY").toUpperCase();
|
|
56
|
+
$("progress-value").textContent = percent >= 0 ? `${percent}%` : "...";
|
|
57
|
+
fill.style.width = percent >= 0 ? `${Math.min(100, percent)}%` : "34%";
|
|
58
|
+
fill.classList.toggle("indeterminate", percent < 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function pollStatus() {
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch("/progress", { cache: "no-store" });
|
|
64
|
+
if (!response.ok) throw new Error("status");
|
|
65
|
+
const progress = await response.json();
|
|
66
|
+
setConnection(true);
|
|
67
|
+
setLiveStatus(progress.status);
|
|
68
|
+
setProgress(progress.status, Number(progress.progress), progress.active);
|
|
69
|
+
} catch (_) {
|
|
70
|
+
setConnection(false);
|
|
71
|
+
setLiveStatus("OFFLINE");
|
|
72
|
+
setProgress("OFFLINE", -1, false);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadDiscInfo() {
|
|
77
|
+
try {
|
|
78
|
+
const response = await fetch("/disc-info", { cache: "no-store" });
|
|
79
|
+
const info = await response.json();
|
|
80
|
+
state.discBytes = Number(info.capacity_bytes || 0);
|
|
81
|
+
state.discType = info.type || "none";
|
|
82
|
+
renderSelection();
|
|
83
|
+
} catch (_) {
|
|
84
|
+
state.discBytes = 0;
|
|
85
|
+
state.discType = "none";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function renderSelection() {
|
|
90
|
+
const list = $("selection-list");
|
|
91
|
+
const total = state.entries.reduce((sum, entry) => sum + entry.file.size, 0);
|
|
92
|
+
const roots = new Map();
|
|
93
|
+
state.entries.forEach((entry, index) => {
|
|
94
|
+
const root = rootFor(entry.path);
|
|
95
|
+
if (!root) return;
|
|
96
|
+
if (!roots.has(root)) roots.set(root, { indexes: [], bytes: 0 });
|
|
97
|
+
const group = roots.get(root);
|
|
98
|
+
group.indexes.push(index);
|
|
99
|
+
group.bytes += entry.file.size;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!state.entries.length) {
|
|
103
|
+
list.innerHTML = '<div class="empty-selection">NO MEDIA SELECTED</div>';
|
|
104
|
+
} else {
|
|
105
|
+
const grouped = new Set();
|
|
106
|
+
const rows = [];
|
|
107
|
+
roots.forEach((group, root) => {
|
|
108
|
+
group.indexes.forEach((index) => grouped.add(index));
|
|
109
|
+
rows.push(`<div class="selection-row"><span class="selection-name">[FOLDER] ${escapeHtml(root)}/</span><span class="selection-meta">${group.indexes.length} FILES // ${formatSize(group.bytes)}</span><button class="remove-selection" type="button" data-remove-group="${escapeHtml(root)}" aria-label="Remove folder">X</button></div>`);
|
|
110
|
+
});
|
|
111
|
+
state.entries.forEach((entry, index) => {
|
|
112
|
+
if (!grouped.has(index)) rows.push(`<div class="selection-row"><span class="selection-name">${escapeHtml(entry.path)}</span><span class="selection-meta">${formatSize(entry.file.size)}</span><button class="remove-selection" type="button" data-remove-index="${index}" aria-label="Remove file">X</button></div>`);
|
|
113
|
+
});
|
|
114
|
+
list.innerHTML = rows.join("");
|
|
115
|
+
list.querySelectorAll("[data-remove-index]").forEach((button) => {
|
|
116
|
+
button.addEventListener("click", () => {
|
|
117
|
+
state.entries.splice(Number(button.dataset.removeIndex), 1);
|
|
118
|
+
renderSelection();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
list.querySelectorAll("[data-remove-group]").forEach((button) => {
|
|
122
|
+
button.addEventListener("click", () => {
|
|
123
|
+
const root = button.dataset.removeGroup;
|
|
124
|
+
state.entries = state.entries.filter((entry) => rootFor(entry.path) !== root);
|
|
125
|
+
renderSelection();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const count = state.entries.length;
|
|
131
|
+
$("selection-summary").textContent = `${count} FILE${count === 1 ? "" : "S"} // ${roots.size} FOLDER${roots.size === 1 ? "" : "S"}`;
|
|
132
|
+
$("upload-button").disabled = count === 0;
|
|
133
|
+
const label = $("disc-label");
|
|
134
|
+
if (!label.value && state.entries.length) label.value = rootFor(state.entries[0].path) || state.entries[0].file.name.replace(/\.[^.]+$/, "");
|
|
135
|
+
|
|
136
|
+
const meter = $("disc-meter");
|
|
137
|
+
if (state.discBytes && count) {
|
|
138
|
+
const percent = Math.min(total / state.discBytes * 100, 100);
|
|
139
|
+
meter.hidden = false;
|
|
140
|
+
$("disc-type").textContent = `DISC: ${state.discType.toUpperCase()}`;
|
|
141
|
+
$("disc-space").textContent = `${formatSize(total)} / ${formatSize(state.discBytes)}`;
|
|
142
|
+
$("disc-fill").style.width = `${percent}%`;
|
|
143
|
+
} else {
|
|
144
|
+
meter.hidden = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function addFiles(fileList) {
|
|
149
|
+
Array.from(fileList).forEach((file) => {
|
|
150
|
+
state.entries.push({ file, path: file.webkitRelativePath || file.name });
|
|
151
|
+
});
|
|
152
|
+
renderSelection();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function setMessage(text, ok) {
|
|
156
|
+
const message = $("form-message");
|
|
157
|
+
message.textContent = text;
|
|
158
|
+
message.className = `form-message ${ok ? "ok" : "error"}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function submitUrl(event) {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
const value = $("url-input").value.trim();
|
|
164
|
+
if (!value) return;
|
|
165
|
+
const button = event.currentTarget.querySelector("button");
|
|
166
|
+
button.disabled = true;
|
|
167
|
+
button.textContent = "QUEUING //";
|
|
168
|
+
try {
|
|
169
|
+
const response = await fetch("/", {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
172
|
+
body: new URLSearchParams({ url: value })
|
|
173
|
+
});
|
|
174
|
+
setMessage(await response.text(), response.ok);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
setMessage(`Request failed: ${error.message}`, false);
|
|
177
|
+
} finally {
|
|
178
|
+
button.disabled = false;
|
|
179
|
+
button.innerHTML = 'BURN TO DISC <span>//</span>';
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function uploadSelection() {
|
|
184
|
+
if (!state.entries.length) return;
|
|
185
|
+
const button = $("upload-button");
|
|
186
|
+
button.disabled = true;
|
|
187
|
+
button.innerHTML = "UPLOADING //";
|
|
188
|
+
setProgress("UPLOADING", 0, true);
|
|
189
|
+
const label = $("disc-label").value.trim();
|
|
190
|
+
try {
|
|
191
|
+
if (label) {
|
|
192
|
+
await fetch("/set-label", {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
195
|
+
body: new URLSearchParams({ label })
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const form = new FormData();
|
|
199
|
+
const paths = [];
|
|
200
|
+
state.entries.forEach((entry) => {
|
|
201
|
+
form.append("files", entry.file, entry.file.name);
|
|
202
|
+
paths.push({ n: entry.file.name, p: entry.path });
|
|
203
|
+
});
|
|
204
|
+
form.append("_paths", JSON.stringify(paths));
|
|
205
|
+
const result = await new Promise((resolve, reject) => {
|
|
206
|
+
const xhr = new XMLHttpRequest();
|
|
207
|
+
xhr.open("POST", "/");
|
|
208
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
209
|
+
if (event.lengthComputable) setProgress("UPLOADING", Math.round(event.loaded / event.total * 100), true);
|
|
210
|
+
});
|
|
211
|
+
xhr.onload = () => resolve({ ok: xhr.status >= 200 && xhr.status < 300, text: xhr.responseText });
|
|
212
|
+
xhr.onerror = () => reject(new Error("network"));
|
|
213
|
+
xhr.send(form);
|
|
214
|
+
});
|
|
215
|
+
setMessage(result.text, result.ok);
|
|
216
|
+
if (result.ok) {
|
|
217
|
+
state.entries = [];
|
|
218
|
+
renderSelection();
|
|
219
|
+
setProgress("UPLOAD READY", 100, false);
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
setMessage(`Upload failed: ${error.message}`, false);
|
|
223
|
+
} finally {
|
|
224
|
+
button.disabled = state.entries.length === 0;
|
|
225
|
+
button.innerHTML = 'UPLOAD TO DISC <span>//</span>';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function setupTabs() {
|
|
230
|
+
document.querySelectorAll(".tab").forEach((tab) => {
|
|
231
|
+
tab.addEventListener("click", () => {
|
|
232
|
+
document.querySelectorAll(".tab").forEach((item) => {
|
|
233
|
+
const active = item === tab;
|
|
234
|
+
item.classList.toggle("active", active);
|
|
235
|
+
item.setAttribute("aria-selected", active ? "true" : "false");
|
|
236
|
+
});
|
|
237
|
+
document.querySelectorAll(".tab-panel").forEach((panel) => { panel.hidden = panel.id !== `tab-${tab.dataset.tab}`; panel.classList.toggle("active", !panel.hidden); });
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
$("file-picker").addEventListener("click", () => $("file-input").click());
|
|
243
|
+
$("folder-picker").addEventListener("click", () => $("folder-input").click());
|
|
244
|
+
$("file-input").addEventListener("change", (event) => { addFiles(event.target.files); event.target.value = ""; });
|
|
245
|
+
$("folder-input").addEventListener("change", (event) => { addFiles(event.target.files); event.target.value = ""; });
|
|
246
|
+
$("upload-button").addEventListener("click", uploadSelection);
|
|
247
|
+
$("url-form").addEventListener("submit", submitUrl);
|
|
248
|
+
const dropzone = $("dropzone");
|
|
249
|
+
if (dropzone) {
|
|
250
|
+
dropzone.addEventListener("dragover", (event) => { event.preventDefault(); event.currentTarget.classList.add("drag"); });
|
|
251
|
+
dropzone.addEventListener("dragleave", (event) => event.currentTarget.classList.remove("drag"));
|
|
252
|
+
dropzone.addEventListener("drop", (event) => { event.preventDefault(); event.currentTarget.classList.remove("drag"); addFiles(event.dataTransfer.files); });
|
|
253
|
+
}
|
|
254
|
+
setupTabs();
|
|
255
|
+
loadDiscInfo();
|
|
256
|
+
pollStatus();
|
|
257
|
+
setInterval(pollStatus, 2000);
|
|
258
|
+
|
|
259
|
+
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=4").catch(() => {});
|
|
260
|
+
window.addEventListener("beforeinstallprompt", (event) => {
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
const button = document.createElement("button");
|
|
263
|
+
button.className = "outline-button install-button";
|
|
264
|
+
button.textContent = "INSTALL APP";
|
|
265
|
+
$("install-slot").appendChild(button);
|
|
266
|
+
button.addEventListener("click", async () => { event.prompt(); await event.userChoice; button.remove(); });
|
|
267
|
+
});
|
|
268
|
+
})();
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<meta name="theme-color" content="#f0ede4">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
9
|
+
<link rel="manifest" href="/manifest.json">
|
|
10
|
+
<link rel="stylesheet" href="/static/style.css">
|
|
11
|
+
<title>DiscStation // Physical Media</title>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div class="page-shell">
|
|
15
|
+
<header class="topbar">
|
|
16
|
+
<a class="brand" href="/" aria-label="DiscStation home">
|
|
17
|
+
<span class="brand-mark">DS</span>
|
|
18
|
+
<span>DISCSTATION</span>
|
|
19
|
+
</a>
|
|
20
|
+
<div class="topbar-actions">
|
|
21
|
+
<button id="theme-toggle" class="theme-toggle" type="button" aria-label="Switch to dark mode">☾</button>
|
|
22
|
+
<div class="connection" aria-live="polite">
|
|
23
|
+
<span id="connection-dot" class="connection-dot checking"></span>
|
|
24
|
+
<span id="connection-label">LINK: CHECKING</span>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
<main>
|
|
30
|
+
<section class="capability-strip section-rule">
|
|
31
|
+
<div><strong>01</strong><span>VIDEO DVD</span></div>
|
|
32
|
+
<div><strong>02</strong><span>DATA DVD</span></div>
|
|
33
|
+
<div><strong>03</strong><span>AUDIO CD</span></div>
|
|
34
|
+
<div><strong>04</strong><span>RIP / PLAY</span></div>
|
|
35
|
+
</section>
|
|
36
|
+
|
|
37
|
+
<section class="burn-panel section-rule" aria-labelledby="burn-title">
|
|
38
|
+
<div class="panel-heading">
|
|
39
|
+
<div>
|
|
40
|
+
<div class="section-kicker">NEW BURN</div>
|
|
41
|
+
<h2 id="burn-title">LOAD THE MEDIA</h2>
|
|
42
|
+
</div>
|
|
43
|
+
<span class="panel-index">DISCSTN-01</span>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="tabs" role="tablist" aria-label="Burn source">
|
|
47
|
+
<button class="tab active" type="button" data-tab="url" role="tab" aria-selected="true">URL / PATH</button>
|
|
48
|
+
<button class="tab" type="button" data-tab="upload" role="tab" aria-selected="false">UPLOAD FILES</button>
|
|
49
|
+
</div>
|
|
50
|
+
<div id="global-progress" class="global-progress" hidden>
|
|
51
|
+
<div><span id="progress-phase">READY</span><span id="progress-value">--</span></div>
|
|
52
|
+
<div class="progress-track"><span id="progress-fill"></span></div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div id="tab-url" class="tab-panel active" role="tabpanel">
|
|
56
|
+
<p class="field-note">YouTube URL, search term, or local path for a video DVD.</p>
|
|
57
|
+
<form id="url-form" action="/" method="post">
|
|
58
|
+
<label class="sr-only" for="url-input">URL or local path</label>
|
|
59
|
+
<input id="url-input" class="url-input" name="url" type="text" autocomplete="off" placeholder="https://... or /path/to/file" required>
|
|
60
|
+
<button class="black-button" type="submit">BURN TO DISC <span>//</span></button>
|
|
61
|
+
</form>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div id="tab-upload" class="tab-panel" role="tabpanel" hidden>
|
|
65
|
+
<p class="field-note">Select files or complete folders. The folder structure is retained on a data disc.</p>
|
|
66
|
+
<div class="disc-meter" id="disc-meter" hidden>
|
|
67
|
+
<div><span id="disc-type">DISC: CHECKING</span><span id="disc-space">0.0 / 0.0 GB</span></div>
|
|
68
|
+
<div class="meter-track"><span id="disc-fill"></span></div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="dropzone" id="dropzone">
|
|
71
|
+
DROP FILES OR FOLDERS HERE
|
|
72
|
+
</div>
|
|
73
|
+
<div class="picker-row">
|
|
74
|
+
<button class="outline-button" type="button" id="file-picker">ADD FILES</button>
|
|
75
|
+
<button class="outline-button" type="button" id="folder-picker">ADD FOLDER</button>
|
|
76
|
+
</div>
|
|
77
|
+
<input id="file-input" type="file" multiple hidden>
|
|
78
|
+
<input id="folder-input" type="file" webkitdirectory directory multiple hidden>
|
|
79
|
+
<div id="selection-list" class="selection-list" aria-live="polite">
|
|
80
|
+
<div class="empty-selection">NO MEDIA SELECTED</div>
|
|
81
|
+
</div>
|
|
82
|
+
<label class="field-label" for="disc-label">DISC LABEL</label>
|
|
83
|
+
<input id="disc-label" class="text-input" type="text" maxlength="32" placeholder="DISCSTATION_ARCHIVE">
|
|
84
|
+
<div id="selection-summary" class="selection-summary">0 FILES // 0 FOLDERS</div>
|
|
85
|
+
<button class="black-button" type="button" id="upload-button" disabled>UPLOAD TO DISC <span>//</span></button>
|
|
86
|
+
</div>
|
|
87
|
+
<div id="form-message" class="form-message" role="status" aria-live="polite"></div>
|
|
88
|
+
</section>
|
|
89
|
+
</main>
|
|
90
|
+
|
|
91
|
+
<section class="footer-note section-rule">
|
|
92
|
+
<div class="eyebrow">DISCSTATION // CONTROL SURFACE</div>
|
|
93
|
+
<div class="footer-note-title">PHYSICAL MEDIA,<br><em>ENGINEERED.</em></div>
|
|
94
|
+
<p>A compact authoring and archival instrument for discs that still deserve a place on the shelf.</p>
|
|
95
|
+
<div class="hero-specs">
|
|
96
|
+
<span>MODEL: ESP32 DEVKIT V1</span>
|
|
97
|
+
<span>REF: DISCSTN-01</span>
|
|
98
|
+
<span>REV: 001</span>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<footer class="footer-stamp section-rule">
|
|
103
|
+
<div><span class="stamp-label">SYSTEM:</span> <span id="live-status">READY</span></div>
|
|
104
|
+
<div>DISCSTATION // REV 001</div>
|
|
105
|
+
</footer>
|
|
106
|
+
</div>
|
|
107
|
+
<div id="install-slot"></div>
|
|
108
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=5").catch(() => {});</script>
|
|
109
|
+
<script src="/static/app.js?v=5" defer></script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>
|