discstation 0.2.0 → 0.3.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 +11 -5
- package/arduino/firmware/discstation.bin +0 -0
- package/arduino/firmware/firmware.json +1 -1
- package/package.json +1 -1
- package/src/discstation.py +29 -4
- package/src/static/app.js +38 -5
- package/src/static/index.html +3 -2
- package/src/static/style.css +27 -1
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) — 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
|
|
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 OLED's spinning-disc screensaver appears on its home/standby screens or after playback has been paused for 15 s, while the web/app remote shows the same spinning disc immediately in place of the visualizer any time there's no real audio data to show (paused, or a host platform that can't capture audio at all) |
|
|
75
75
|
| **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
|
|
76
76
|
|
|
77
77
|
## Project Structure
|
|
@@ -175,7 +175,9 @@ self-signed cert. On startup the host prints the LAN URL to open
|
|
|
175
175
|
— and picking one starts it directly, no separate confirm step. Its
|
|
176
176
|
screen mirrors the physical OLED remote: the disc title/status and offered
|
|
177
177
|
modes at idle, and a glowing peak-hold spectrum visualizer that fills the
|
|
178
|
-
width of the screen during playback (see Play, above)
|
|
178
|
+
width of the screen during playback (see Play, above) - or a spinning-disc
|
|
179
|
+
animation in its place whenever there's no real audio data to visualize
|
|
180
|
+
(paused, or a host platform with no audio capture). EJECT/CLOSE toggle,
|
|
179
181
|
volume, and full playback transport including PREV/NEXT track for audio
|
|
180
182
|
CDs. Greys out and auto-collapses the instant a physical remote is
|
|
181
183
|
detected, so the two never fight for control — including mid-burn/rip/play:
|
|
@@ -190,9 +192,13 @@ The `mobile/` Expo app (enter the host's LAN address in its settings screen)
|
|
|
190
192
|
has the same on-screen remote as the web UI, rendered inline in the main
|
|
191
193
|
screen rather than a popup — same "REMOTE" tag toggle, opens automatically
|
|
192
194
|
with no ESP32 attached, mode buttons filtered to what the disc actually
|
|
193
|
-
supports, and picking one starts it directly.
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
supports, and picking one starts it directly. Its screen has the same
|
|
196
|
+
idle title/offered-modes view, live spectrum visualizer (or spinning-disc
|
|
197
|
+
fallback when there's no real audio data to show) and PREV/NEXT track
|
|
198
|
+
controls for audio CDs as the web UI's playback screen - subscribed to the
|
|
199
|
+
same `/events` SSE stream. Same auto-collapse behavior when a physical ESP32
|
|
200
|
+
shows up — mid-operation and without interruption, same as the web UI — and
|
|
201
|
+
the same underlying `/remote/button` protocol.
|
|
196
202
|
|
|
197
203
|
## Disc Support
|
|
198
204
|
|
|
Binary file
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.
|
|
1
|
+
{"version":"0.3.0","size":1260592,"md5":"e0959615d438880783baf38f8943ae3f"}
|
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -600,10 +600,35 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
600
600
|
# the one already running (CANCEL/EJECT stay available regardless).
|
|
601
601
|
self._respond(200, json.dumps({**_last_disc_info, "busy": True, "menu_items": [], "appliance": _appliance_mode}), "application/json")
|
|
602
602
|
return
|
|
603
|
+
device = discstation_burn.disc_device()
|
|
604
|
+
if not _detect_lock.acquire(timeout=1.5):
|
|
605
|
+
# Something else (almost always the background poller) is already
|
|
606
|
+
# deep into a classify for this disc - waiting on detect_disc()'s
|
|
607
|
+
# own (unbounded, blocking) internal acquire would just queue
|
|
608
|
+
# behind it for however long that takes (seen ~90s on a disc whose
|
|
609
|
+
# blkid/wodim-toc probes both time out) - an earlier version of
|
|
610
|
+
# this fix acquired-then-released this same lock before calling
|
|
611
|
+
# detect_disc() as a "quick test," which didn't actually help: by
|
|
612
|
+
# the time detect_disc() re-acquired the lock for real, another
|
|
613
|
+
# thread could (and, live-tested, did) grab it first and leave
|
|
614
|
+
# this request blocked on that *second*, timeout-less acquire
|
|
615
|
+
# anyway. Holding the lock we already have for the whole classify
|
|
616
|
+
# - calling _detect_disc_locked() directly instead of going back
|
|
617
|
+
# through detect_disc() - is what actually bounds the wait.
|
|
618
|
+
stale = {**_last_disc_info}
|
|
619
|
+
stale.setdefault("type", "reading")
|
|
620
|
+
self._respond(200, json.dumps({**stale, "appliance": _appliance_mode}), "application/json")
|
|
621
|
+
return
|
|
603
622
|
info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
|
|
604
623
|
try:
|
|
605
|
-
|
|
606
|
-
|
|
624
|
+
try:
|
|
625
|
+
cached = _detect_cache.get(device)
|
|
626
|
+
if cached and time.monotonic() - cached[0] < DISC_DETECT_CACHE_TTL:
|
|
627
|
+
di = cached[1]
|
|
628
|
+
else:
|
|
629
|
+
di = _detect_disc_locked(device, settle=False, budget=15)
|
|
630
|
+
finally:
|
|
631
|
+
_detect_lock.release()
|
|
607
632
|
info["disc_present"] = di.present
|
|
608
633
|
info["capacity_bytes"] = di.capacity_bytes
|
|
609
634
|
info["capacity_gb"] = round(di.capacity_bytes / 1e9, 2)
|
|
@@ -673,7 +698,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
|
|
|
673
698
|
def _serve_sw(self):
|
|
674
699
|
sw = '''self.addEventListener('install', e => {
|
|
675
700
|
self.skipWaiting();
|
|
676
|
-
caches.open('discstation-
|
|
701
|
+
caches.open('discstation-v47').then(c => c.addAll(['/','/static/style.css?v=47','/static/app.js?v=47']));
|
|
677
702
|
});
|
|
678
703
|
self.addEventListener('activate', e => e.waitUntil(clients.claim()));
|
|
679
704
|
self.addEventListener('fetch', e => {
|
|
@@ -682,7 +707,7 @@ self.addEventListener('fetch', e => {
|
|
|
682
707
|
if (path === '/' || path.startsWith('/static/')) {
|
|
683
708
|
e.respondWith(fetch(e.request).then(r => {
|
|
684
709
|
const copy = r.clone();
|
|
685
|
-
caches.open('discstation-
|
|
710
|
+
caches.open('discstation-v47').then(c => c.put(e.request, copy));
|
|
686
711
|
return r;
|
|
687
712
|
}).catch(() => caches.match(e.request)));
|
|
688
713
|
} else {
|
package/src/static/app.js
CHANGED
|
@@ -212,6 +212,33 @@
|
|
|
212
212
|
rpBarFillEls.forEach((fill) => { fill.style.height = "0%"; });
|
|
213
213
|
rpBarPeakEls.forEach((peak) => { peak.style.bottom = "0%"; peak.style.opacity = "0"; });
|
|
214
214
|
if (rpBarRafId !== null) { cancelAnimationFrame(rpBarRafId); rpBarRafId = null; }
|
|
215
|
+
lastVuFrameAt = 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Real VU data only ever flows on Linux (PulseAudio capture - see
|
|
219
|
+
// docs/PLATFORM_SUPPORT.md for why macOS/Windows can't) and stops the moment
|
|
220
|
+
// playback is paused even there, but `progress.playing` stays true throughout
|
|
221
|
+
// a pause - so "is a real frame actually arriving right now" has to be tracked
|
|
222
|
+
// separately, mirroring the firmware's own lastVuAt/VU_TIMEOUT_MS fallback
|
|
223
|
+
// (arduino/DiscStation/DiscStation.ino). Whenever it isn't, show a spinning
|
|
224
|
+
// disc in the same slot instead of empty/frozen bars.
|
|
225
|
+
let lastVuFrameAt = 0;
|
|
226
|
+
let rpLastIdleWithDisc = false;
|
|
227
|
+
const VU_TIMEOUT_MS = 1200;
|
|
228
|
+
function updateVuSlot() {
|
|
229
|
+
// idleWithDisc owns the slot instead (the title+modes menu) - otherwise
|
|
230
|
+
// this slot is always showing *something*: real bars while they're
|
|
231
|
+
// actually flowing, the spinning disc otherwise - covering paused,
|
|
232
|
+
// platforms with no VU capture, and plain idle-with-no-disc alike (that
|
|
233
|
+
// last one used to just sit blank).
|
|
234
|
+
if (rpLastIdleWithDisc) {
|
|
235
|
+
$("rp-bars").hidden = true;
|
|
236
|
+
$("rp-discsaver").hidden = true;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const vuFlowing = rpLastPlaying && Date.now() - lastVuFrameAt < VU_TIMEOUT_MS;
|
|
240
|
+
$("rp-bars").hidden = !vuFlowing;
|
|
241
|
+
$("rp-discsaver").hidden = vuFlowing;
|
|
215
242
|
}
|
|
216
243
|
|
|
217
244
|
let rpLastPlaying = false;
|
|
@@ -232,10 +259,12 @@
|
|
|
232
259
|
// never chops a word off abruptly mid-string.
|
|
233
260
|
$("rp-status").textContent = (progress.status || "READY").toUpperCase();
|
|
234
261
|
}
|
|
235
|
-
// Bars
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
|
|
262
|
+
// Bars (or, lacking real VU data, the spinning-disc fallback) belong on
|
|
263
|
+
// screen only while something is actually playing - not just "not
|
|
264
|
+
// idle-with-a-disc" (that left them visibly stuck at their last frame once
|
|
265
|
+
// playback stopped with no disc left to show a menu for instead).
|
|
266
|
+
rpLastIdleWithDisc = idleWithDisc;
|
|
267
|
+
updateVuSlot();
|
|
239
268
|
if (wasPlaying && !progress.playing) resetVuBars(); // clear the last frame, don't leave it stuck
|
|
240
269
|
$("rp-menu").hidden = !idleWithDisc;
|
|
241
270
|
if (idleWithDisc) {
|
|
@@ -588,7 +617,7 @@
|
|
|
588
617
|
let d;
|
|
589
618
|
try { d = JSON.parse(ev.data); } catch (_) { return; }
|
|
590
619
|
if (d.type === "disc-changed") { loadDiscInfo(); return; }
|
|
591
|
-
if (d.type === "vu") { renderVuBars(d.levels); return; }
|
|
620
|
+
if (d.type === "vu") { lastVuFrameAt = Date.now(); renderVuBars(d.levels); updateVuSlot(); return; }
|
|
592
621
|
setConnection(true);
|
|
593
622
|
setLiveStatus(d.status);
|
|
594
623
|
setProgress(d.status, Number(d.progress), d.active);
|
|
@@ -604,6 +633,10 @@
|
|
|
604
633
|
// Backstops: catch a zombie SSE connection, and refresh disc state slowly.
|
|
605
634
|
setInterval(() => { if (!es || es.readyState !== 1) pollStatus(); }, 8000);
|
|
606
635
|
setInterval(loadDiscInfo, 15000);
|
|
636
|
+
// Nothing else re-checks the clock once VU frames stop arriving (pause, a
|
|
637
|
+
// host platform with no capture, a dropped connection) - this is what
|
|
638
|
+
// actually notices and flips to the spinning-disc fallback promptly.
|
|
639
|
+
setInterval(updateVuSlot, 300);
|
|
607
640
|
}
|
|
608
641
|
|
|
609
642
|
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=8").catch(() => {});
|
package/src/static/index.html
CHANGED
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
<div class="rp-screen">
|
|
67
67
|
<div id="rp-status" class="rp-status">READY</div>
|
|
68
68
|
<div id="rp-bars" class="rp-bars"></div>
|
|
69
|
+
<div id="rp-discsaver" class="rp-discsaver" hidden></div>
|
|
69
70
|
<div id="rp-menu" class="rp-menu" hidden></div>
|
|
70
71
|
</div>
|
|
71
72
|
<input id="rp-volume" type="range" min="0" max="100" value="70" aria-label="Volume">
|
|
@@ -150,7 +151,7 @@
|
|
|
150
151
|
</footer>
|
|
151
152
|
</div>
|
|
152
153
|
<div id="install-slot"></div>
|
|
153
|
-
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=
|
|
154
|
-
<script src="/static/app.js?v=
|
|
154
|
+
<script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=47").catch(() => {});</script>
|
|
155
|
+
<script src="/static/app.js?v=47" defer></script>
|
|
155
156
|
</body>
|
|
156
157
|
</html>
|
package/src/static/style.css
CHANGED
|
@@ -386,7 +386,33 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
|
|
|
386
386
|
default [hidden] { display: none } (author styles always win over the UA
|
|
387
387
|
stylesheet) - the `hidden` attribute was being set correctly in JS but doing
|
|
388
388
|
nothing visually without this. */
|
|
389
|
-
.rp-bars[hidden], .rp-menu[hidden] { display: none; }
|
|
389
|
+
.rp-bars[hidden], .rp-menu[hidden], .rp-discsaver[hidden] { display: none; }
|
|
390
|
+
|
|
391
|
+
/* Fallback for whenever real VU data isn't actually flowing (paused, a host
|
|
392
|
+
platform with no audio-capture support, or just-started-playing before the
|
|
393
|
+
first frame arrives) - see updateVuSlot() in app.js. A CSS-only spinning
|
|
394
|
+
vinyl-record glyph, matching this page's existing all-CSS/Unicode-glyph
|
|
395
|
+
iconography (no SVG/bitmap assets anywhere else in this file). */
|
|
396
|
+
.rp-discsaver {
|
|
397
|
+
flex: 1;
|
|
398
|
+
display: flex;
|
|
399
|
+
align-items: center;
|
|
400
|
+
justify-content: center;
|
|
401
|
+
border-top: 1px solid #262626;
|
|
402
|
+
padding-top: 4px;
|
|
403
|
+
}
|
|
404
|
+
.rp-discsaver::before {
|
|
405
|
+
content: "";
|
|
406
|
+
width: 34px;
|
|
407
|
+
height: 34px;
|
|
408
|
+
border-radius: 50%;
|
|
409
|
+
border: 2px solid var(--accent);
|
|
410
|
+
background: radial-gradient(circle, #0a0a0a 0 22%, transparent 23%),
|
|
411
|
+
repeating-radial-gradient(var(--ink) 0 1px, transparent 1px 4px);
|
|
412
|
+
box-shadow: 0 0 6px var(--accent);
|
|
413
|
+
animation: rp-disc-spin 2.4s linear infinite;
|
|
414
|
+
}
|
|
415
|
+
@keyframes rp-disc-spin { to { transform: rotate(360deg); } }
|
|
390
416
|
.rp-main input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|
391
417
|
.rp-buttons { display: flex; flex-direction: column; gap: 8px; }
|
|
392
418
|
.rp-btn {
|