discstation 0.2.0 → 0.3.1

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.1","size":1260592,"md5":"77e56fa726b756dde0f081779164e880"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -483,6 +483,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
483
483
  upload_dir = Path(discstation_burn.WORK) / f"upload_{time.strftime('%Y%m%d_%H%M%S')}"
484
484
  upload_dir.mkdir(parents=True, exist_ok=True)
485
485
  total = 0
486
+ upload_order = [] # relative paths, in the order the client sent them
486
487
  for i, (filename, data) in enumerate(files):
487
488
  rel = filename
488
489
  if i < len(paths_list) and paths_list[i].get('p'):
@@ -495,7 +496,14 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
495
496
  continue
496
497
  dest.parent.mkdir(parents=True, exist_ok=True)
497
498
  dest.write_bytes(data)
499
+ upload_order.append(rel.lstrip('/'))
498
500
  total += len(data)
501
+ # burn_audio_flow() prefers this order over its alphabetical fallback,
502
+ # so an audio burn tracks the order files were actually selected/
503
+ # arranged on the web page or app, not whatever order their filenames
504
+ # happen to sort in (the bug: picking "03 intro, 01 verse, 02 chorus"
505
+ # used to always burn 01/02/03 regardless of pick order).
506
+ (upload_dir / ".upload_order.json").write_text(json.dumps(upload_order))
499
507
  # wait_for_web_url() (already running on the OLED side once a burn
500
508
  # mode is selected first - the normal flow: scan the QR code, then
501
509
  # upload) only ever reads from _burn_url_queue, same as a pasted
@@ -600,10 +608,35 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
600
608
  # the one already running (CANCEL/EJECT stay available regardless).
601
609
  self._respond(200, json.dumps({**_last_disc_info, "busy": True, "menu_items": [], "appliance": _appliance_mode}), "application/json")
602
610
  return
611
+ device = discstation_burn.disc_device()
612
+ if not _detect_lock.acquire(timeout=1.5):
613
+ # Something else (almost always the background poller) is already
614
+ # deep into a classify for this disc - waiting on detect_disc()'s
615
+ # own (unbounded, blocking) internal acquire would just queue
616
+ # behind it for however long that takes (seen ~90s on a disc whose
617
+ # blkid/wodim-toc probes both time out) - an earlier version of
618
+ # this fix acquired-then-released this same lock before calling
619
+ # detect_disc() as a "quick test," which didn't actually help: by
620
+ # the time detect_disc() re-acquired the lock for real, another
621
+ # thread could (and, live-tested, did) grab it first and leave
622
+ # this request blocked on that *second*, timeout-less acquire
623
+ # anyway. Holding the lock we already have for the whole classify
624
+ # - calling _detect_disc_locked() directly instead of going back
625
+ # through detect_disc() - is what actually bounds the wait.
626
+ stale = {**_last_disc_info}
627
+ stale.setdefault("type", "reading")
628
+ self._respond(200, json.dumps({**stale, "appliance": _appliance_mode}), "application/json")
629
+ return
603
630
  info = {"disc_present": False, "capacity_bytes": 0, "capacity_gb": 0, "type": "none"}
604
631
  try:
605
- device = discstation_burn.disc_device()
606
- di = detect_disc(device, settle=False, budget=15)
632
+ try:
633
+ cached = _detect_cache.get(device)
634
+ if cached and time.monotonic() - cached[0] < DISC_DETECT_CACHE_TTL:
635
+ di = cached[1]
636
+ else:
637
+ di = _detect_disc_locked(device, settle=False, budget=15)
638
+ finally:
639
+ _detect_lock.release()
607
640
  info["disc_present"] = di.present
608
641
  info["capacity_bytes"] = di.capacity_bytes
609
642
  info["capacity_gb"] = round(di.capacity_bytes / 1e9, 2)
@@ -673,7 +706,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
673
706
  def _serve_sw(self):
674
707
  sw = '''self.addEventListener('install', e => {
675
708
  self.skipWaiting();
676
- caches.open('discstation-v46').then(c => c.addAll(['/','/static/style.css?v=46','/static/app.js?v=46']));
709
+ caches.open('discstation-v48').then(c => c.addAll(['/','/static/style.css?v=48','/static/app.js?v=48']));
677
710
  });
678
711
  self.addEventListener('activate', e => e.waitUntil(clients.claim()));
679
712
  self.addEventListener('fetch', e => {
@@ -682,7 +715,7 @@ self.addEventListener('fetch', e => {
682
715
  if (path === '/' || path.startsWith('/static/')) {
683
716
  e.respondWith(fetch(e.request).then(r => {
684
717
  const copy = r.clone();
685
- caches.open('discstation-v46').then(c => c.put(e.request, copy));
718
+ caches.open('discstation-v48').then(c => c.put(e.request, copy));
686
719
  return r;
687
720
  }).catch(() => caches.match(e.request)));
688
721
  } else {
@@ -1808,8 +1841,15 @@ def drive_status(device):
1808
1841
  except OSError:
1809
1842
  return "unknown"
1810
1843
  try:
1811
- st = fcntl.ioctl(fd, _CDROM_DRIVE_STATUS, 0)
1812
- except OSError:
1844
+ # Hard-timeout, not just the OSError below: on a wedged/dying drive this
1845
+ # ioctl can block in uninterruptible D-state (blk_execute_rq) forever -
1846
+ # O_NONBLOCK on the fd doesn't bound a block-layer command wait. Without
1847
+ # this, a stuck call here holds _detect_lock forever and wedges the
1848
+ # whole service (every other thread needing disc state queues up behind
1849
+ # it) - see _run_hard_timeout's own docstring for why this is the only
1850
+ # way to get control back.
1851
+ st = _run_hard_timeout(lambda: fcntl.ioctl(fd, _CDROM_DRIVE_STATUS, 0), timeout=3)
1852
+ except Exception:
1813
1853
  return "unknown"
1814
1854
  finally:
1815
1855
  try:
@@ -3182,13 +3222,30 @@ def burn_audio_flow(ser):
3182
3222
  audio_files = []
3183
3223
  audio_exts = {".wav", ".flac", ".mp3", ".aac", ".ogg", ".wma", ".m4a", ".opus"}
3184
3224
  if src_path.is_dir():
3185
- # rglob, not iterdir: a browser folder upload preserves the
3186
- # original relative paths (e.g. "Album Name/track.flac"), so the
3187
- # audio files usually sit one level below src_path, not at its
3188
- # top - a plain iterdir() only ever saw the subfolder itself.
3189
- for f in sorted(src_path.rglob("*")):
3190
- if f.is_file() and f.suffix.lower() in audio_exts:
3191
- audio_files.append(f)
3225
+ # Prefer the order the web/app upload actually sent (.upload_order.json,
3226
+ # written by _handle_upload) over re-deriving one from the filesystem -
3227
+ # burning in alphabetical-by-filename order regardless of how the user
3228
+ # arranged/picked the files was the whole bug this guards against.
3229
+ order_file = src_path / ".upload_order.json"
3230
+ if order_file.is_file():
3231
+ try:
3232
+ for rel in json.loads(order_file.read_text()):
3233
+ f = src_path / rel
3234
+ if f.is_file() and f.suffix.lower() in audio_exts:
3235
+ audio_files.append(f)
3236
+ except Exception as e:
3237
+ print(f"Upload order manifest unreadable, falling back to alphabetical: {e}")
3238
+ audio_files = []
3239
+ if not audio_files:
3240
+ # rglob, not iterdir: a browser folder upload preserves the
3241
+ # original relative paths (e.g. "Album Name/track.flac"), so the
3242
+ # audio files usually sit one level below src_path, not at its
3243
+ # top - a plain iterdir() only ever saw the subfolder itself.
3244
+ # Also the fallback for a typed/pasted directory path (no upload,
3245
+ # so no order manifest) - alphabetical is the best guess there.
3246
+ for f in sorted(src_path.rglob("*")):
3247
+ if f.is_file() and f.suffix.lower() in audio_exts:
3248
+ audio_files.append(f)
3192
3249
  elif src_path.is_file():
3193
3250
  audio_files = [src_path]
3194
3251
 
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) {
@@ -298,6 +327,29 @@
298
327
  });
299
328
  initRpBars();
300
329
 
330
+ // Fullscreen toggle for the OLED-mirror screen (e.g. blown up on a
331
+ // monitor/TV while music plays). iOS Safari has no element-level
332
+ // Fullscreen API - feature-detect and hide the button there instead of
333
+ // leaving a control that does nothing.
334
+ {
335
+ const rpScreen = $("rp-screen");
336
+ const fsBtn = $("rp-fullscreen-btn");
337
+ const requestFs = rpScreen.requestFullscreen || rpScreen.webkitRequestFullscreen;
338
+ const exitFs = document.exitFullscreen || document.webkitExitFullscreen;
339
+ if (!requestFs || !exitFs) {
340
+ fsBtn.hidden = true;
341
+ } else {
342
+ const isFs = () => (document.fullscreenElement || document.webkitFullscreenElement) === rpScreen;
343
+ const syncLabel = () => fsBtn.setAttribute("aria-label", isFs() ? "Exit fullscreen" : "Fullscreen");
344
+ fsBtn.addEventListener("click", () => {
345
+ if (isFs()) exitFs.call(document);
346
+ else requestFs.call(rpScreen);
347
+ });
348
+ document.addEventListener("fullscreenchange", syncLabel);
349
+ document.addEventListener("webkitfullscreenchange", syncLabel);
350
+ }
351
+ }
352
+
301
353
  // Firmware row: outside the remote panel on purpose - a hardware remote hides
302
354
  // that panel, and hardware is exactly when there is a remote to update.
303
355
  function applyFirmwareState(progress) {
@@ -588,7 +640,7 @@
588
640
  let d;
589
641
  try { d = JSON.parse(ev.data); } catch (_) { return; }
590
642
  if (d.type === "disc-changed") { loadDiscInfo(); return; }
591
- if (d.type === "vu") { renderVuBars(d.levels); return; }
643
+ if (d.type === "vu") { lastVuFrameAt = Date.now(); renderVuBars(d.levels); updateVuSlot(); return; }
592
644
  setConnection(true);
593
645
  setLiveStatus(d.status);
594
646
  setProgress(d.status, Number(d.progress), d.active);
@@ -604,6 +656,10 @@
604
656
  // Backstops: catch a zombie SSE connection, and refresh disc state slowly.
605
657
  setInterval(() => { if (!es || es.readyState !== 1) pollStatus(); }, 8000);
606
658
  setInterval(loadDiscInfo, 15000);
659
+ // Nothing else re-checks the clock once VU frames stop arriving (pause, a
660
+ // host platform with no capture, a dropped connection) - this is what
661
+ // actually notices and flips to the spinning-disc fallback promptly.
662
+ setInterval(updateVuSlot, 300);
607
663
  }
608
664
 
609
665
  if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=8").catch(() => {});
@@ -63,9 +63,11 @@
63
63
  <button class="rp-btn rp-trackbtn" type="button" data-role="prev" data-cmd="REW:BIG" aria-label="Previous track"><span class="rp-glyph">&#x23EE;</span></button>
64
64
  <button class="rp-btn rp-trackbtn" type="button" data-role="next" data-cmd="FF:BIG" aria-label="Next track"><span class="rp-glyph">&#x23ED;</span></button>
65
65
  </div>
66
- <div class="rp-screen">
66
+ <div id="rp-screen" class="rp-screen">
67
+ <button id="rp-fullscreen-btn" class="rp-fullscreen-btn" type="button" aria-label="Fullscreen">&#x26F6;</button>
67
68
  <div id="rp-status" class="rp-status">READY</div>
68
69
  <div id="rp-bars" class="rp-bars"></div>
70
+ <div id="rp-discsaver" class="rp-discsaver" hidden></div>
69
71
  <div id="rp-menu" class="rp-menu" hidden></div>
70
72
  </div>
71
73
  <input id="rp-volume" type="range" min="0" max="100" value="70" aria-label="Volume">
@@ -150,7 +152,7 @@
150
152
  </footer>
151
153
  </div>
152
154
  <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>
155
+ <script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=48").catch(() => {});</script>
156
+ <script src="/static/app.js?v=48" defer></script>
155
157
  </body>
156
158
  </html>
@@ -317,6 +317,7 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
317
317
  }
318
318
  .rp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
319
319
  .rp-screen {
320
+ position: relative;
320
321
  background: #0a0a0a;
321
322
  border: 1px solid var(--ink);
322
323
  border-radius: 3px;
@@ -328,6 +329,38 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
328
329
  flex-direction: column;
329
330
  gap: 8px;
330
331
  }
332
+ /* Corner overlay, not a layout element - doesn't push the status/bars around. */
333
+ .rp-fullscreen-btn {
334
+ position: absolute;
335
+ top: 4px;
336
+ right: 6px;
337
+ z-index: 1;
338
+ border: 0;
339
+ background: none;
340
+ color: #8a8a8a;
341
+ font-size: 13px;
342
+ line-height: 1;
343
+ padding: 2px;
344
+ cursor: pointer;
345
+ opacity: 0.5;
346
+ transition: opacity 0.15s linear;
347
+ }
348
+ .rp-fullscreen-btn:hover, .rp-fullscreen-btn:focus-visible { opacity: 1; }
349
+ .rp-fullscreen-btn[hidden] { display: none; }
350
+ /* Blown up to fill the display (e.g. a monitor/TV while music plays) - same
351
+ OLED look, just bigger. .rp-bars/.rp-bar-col need no changes: they already
352
+ fill available space, and buildRpBars' ResizeObserver (app.js) recomputes
353
+ bar count from the now much wider #rp-bars on its own. */
354
+ .rp-screen:fullscreen, .rp-screen:-webkit-full-screen {
355
+ padding: 28px 32px 24px;
356
+ justify-content: center;
357
+ }
358
+ .rp-screen:fullscreen .rp-status, .rp-screen:-webkit-full-screen .rp-status {
359
+ font-size: 22px;
360
+ }
361
+ .rp-screen:fullscreen .rp-fullscreen-btn, .rp-screen:-webkit-full-screen .rp-fullscreen-btn {
362
+ font-size: 20px;
363
+ }
331
364
  .rp-status {
332
365
  color: #f2f2f2;
333
366
  font-family: var(--mono);
@@ -386,7 +419,33 @@ h2 { margin-top: 8px; font-family: var(--display); font-size: clamp(30px, 7vw, 6
386
419
  default [hidden] { display: none } (author styles always win over the UA
387
420
  stylesheet) - the `hidden` attribute was being set correctly in JS but doing
388
421
  nothing visually without this. */
389
- .rp-bars[hidden], .rp-menu[hidden] { display: none; }
422
+ .rp-bars[hidden], .rp-menu[hidden], .rp-discsaver[hidden] { display: none; }
423
+
424
+ /* Fallback for whenever real VU data isn't actually flowing (paused, a host
425
+ platform with no audio-capture support, or just-started-playing before the
426
+ first frame arrives) - see updateVuSlot() in app.js. A CSS-only spinning
427
+ vinyl-record glyph, matching this page's existing all-CSS/Unicode-glyph
428
+ iconography (no SVG/bitmap assets anywhere else in this file). */
429
+ .rp-discsaver {
430
+ flex: 1;
431
+ display: flex;
432
+ align-items: center;
433
+ justify-content: center;
434
+ border-top: 1px solid #262626;
435
+ padding-top: 4px;
436
+ }
437
+ .rp-discsaver::before {
438
+ content: "";
439
+ width: 34px;
440
+ height: 34px;
441
+ border-radius: 50%;
442
+ border: 2px solid var(--accent);
443
+ background: radial-gradient(circle, #0a0a0a 0 22%, transparent 23%),
444
+ repeating-radial-gradient(var(--ink) 0 1px, transparent 1px 4px);
445
+ box-shadow: 0 0 6px var(--accent);
446
+ animation: rp-disc-spin 2.4s linear infinite;
447
+ }
448
+ @keyframes rp-disc-spin { to { transform: rotate(360deg); } }
390
449
  .rp-main input[type="range"] { width: 100%; accent-color: var(--accent); }
391
450
  .rp-buttons { display: flex; flex-direction: column; gap: 8px; }
392
451
  .rp-btn {