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 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 only appears on the home/standby screens or after playback has been paused for 15 s |
74
+ | **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — both the OLED remote and the web/app on-screen remote show a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`); while a track plays the screen keeps the track/volume view (or the visualizer) up — the 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). EJECT/CLOSE toggle,
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. Same auto-collapse behavior
194
- when a physical ESP32 shows up — mid-operation and without interruption, same
195
- as the web UI — and the same underlying `/remote/button` protocol.
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.2.0","size":1260592,"md5":"94fb8a04146735daa1824da53304702b"}
1
+ {"version":"0.3.0","size":1260592,"md5":"e0959615d438880783baf38f8943ae3f"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -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
- device = discstation_burn.disc_device()
606
- di = detect_disc(device, settle=False, budget=15)
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-v46').then(c => c.addAll(['/','/static/style.css?v=46','/static/app.js?v=46']));
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-v46').then(c => c.put(e.request, copy));
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 belong on screen only while something is actually playing - not just
236
- // "not idle-with-a-disc" (that left them visibly stuck at their last frame
237
- // once playback stopped with no disc left to show a menu for instead).
238
- $("rp-bars").hidden = !progress.playing;
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(() => {});
@@ -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=46").catch(() => {});</script>
154
- <script src="/static/app.js?v=46" defer></script>
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>
@@ -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 {