discstation 0.3.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.
Binary file
@@ -1 +1 @@
1
- {"version":"0.3.0","size":1260592,"md5":"e0959615d438880783baf38f8943ae3f"}
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.3.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
@@ -698,7 +706,7 @@ class _WebHandler(http.server.BaseHTTPRequestHandler):
698
706
  def _serve_sw(self):
699
707
  sw = '''self.addEventListener('install', e => {
700
708
  self.skipWaiting();
701
- caches.open('discstation-v47').then(c => c.addAll(['/','/static/style.css?v=47','/static/app.js?v=47']));
709
+ caches.open('discstation-v48').then(c => c.addAll(['/','/static/style.css?v=48','/static/app.js?v=48']));
702
710
  });
703
711
  self.addEventListener('activate', e => e.waitUntil(clients.claim()));
704
712
  self.addEventListener('fetch', e => {
@@ -707,7 +715,7 @@ self.addEventListener('fetch', e => {
707
715
  if (path === '/' || path.startsWith('/static/')) {
708
716
  e.respondWith(fetch(e.request).then(r => {
709
717
  const copy = r.clone();
710
- caches.open('discstation-v47').then(c => c.put(e.request, copy));
718
+ caches.open('discstation-v48').then(c => c.put(e.request, copy));
711
719
  return r;
712
720
  }).catch(() => caches.match(e.request)));
713
721
  } else {
@@ -1833,8 +1841,15 @@ def drive_status(device):
1833
1841
  except OSError:
1834
1842
  return "unknown"
1835
1843
  try:
1836
- st = fcntl.ioctl(fd, _CDROM_DRIVE_STATUS, 0)
1837
- 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:
1838
1853
  return "unknown"
1839
1854
  finally:
1840
1855
  try:
@@ -3207,13 +3222,30 @@ def burn_audio_flow(ser):
3207
3222
  audio_files = []
3208
3223
  audio_exts = {".wav", ".flac", ".mp3", ".aac", ".ogg", ".wma", ".m4a", ".opus"}
3209
3224
  if src_path.is_dir():
3210
- # rglob, not iterdir: a browser folder upload preserves the
3211
- # original relative paths (e.g. "Album Name/track.flac"), so the
3212
- # audio files usually sit one level below src_path, not at its
3213
- # top - a plain iterdir() only ever saw the subfolder itself.
3214
- for f in sorted(src_path.rglob("*")):
3215
- if f.is_file() and f.suffix.lower() in audio_exts:
3216
- 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)
3217
3249
  elif src_path.is_file():
3218
3250
  audio_files = [src_path]
3219
3251
 
package/src/static/app.js CHANGED
@@ -327,6 +327,29 @@
327
327
  });
328
328
  initRpBars();
329
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
+
330
353
  // Firmware row: outside the remote panel on purpose - a hardware remote hides
331
354
  // that panel, and hardware is exactly when there is a remote to update.
332
355
  function applyFirmwareState(progress) {
@@ -63,7 +63,8 @@
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>
69
70
  <div id="rp-discsaver" class="rp-discsaver" hidden></div>
@@ -151,7 +152,7 @@
151
152
  </footer>
152
153
  </div>
153
154
  <div id="install-slot"></div>
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
+ <script>if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js?v=48").catch(() => {});</script>
156
+ <script src="/static/app.js?v=48" defer></script>
156
157
  </body>
157
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);