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 +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 +70 -13
- package/src/static/app.js +61 -5
- package/src/static/index.html +5 -3
- package/src/static/style.css +60 -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.1","size":1260592,"md5":"77e56fa726b756dde0f081779164e880"}
|
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -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
|
-
|
|
606
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
1812
|
-
|
|
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
|
-
#
|
|
3186
|
-
#
|
|
3187
|
-
#
|
|
3188
|
-
#
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
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
|
|
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) {
|
|
@@ -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(() => {});
|
package/src/static/index.html
CHANGED
|
@@ -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">⏮</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">⏭</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">⛶</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=
|
|
154
|
-
<script src="/static/app.js?v=
|
|
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>
|
package/src/static/style.css
CHANGED
|
@@ -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 {
|