webapp: introduce Laravel app under webapp/ for scene overlays
Replaces the per-scene HTML directories (landing/, loading/, game/,
desktop/, goodbye/, music-box/, music/) with a single Laravel app
serving every overlay over HTTP. Supervisord runs `php artisan serve`
on 127.0.0.1:1118 and the OBS scene JSON now references HTTP routes
instead of file:// URLs.
Highlights:
- public/css/hud.css consolidates the duplicated HUD chrome,
scanlines/vignette/flicker, terminal styling, and pulse keyframes
that were copy-pasted across all seven scenes.
- Blade partials own hud-strip, crt-overlays, obs-ws-scripts,
camera-frame, screen-frame; the seven scenes extend a shared
overlay layout.
- Artisan commands (`rig:setup`, `rig:telemetry`, `rig:loading`,
`rig:playlist`, `rig:cmd`) replace the shell scripts that wrote
per-rig JSON snapshots. TwitchHelix + HardwareSnapshot services
handle the work the bash + Python helpers used to.
- ObsWsClient + MusicCommandController kill the 250 ms cmd.js poll
in the music daemon: POST /cmd/{skip|prev|pause|resume} opens a
short-lived Pawl WS, authenticates, and broadcasts mpd:cmd.
- AudioController streams files from the configured music dirs so
CEF can load tracks under the HTTP origin (Chromium blocks
HTTP-origin pages from loading file:// media).
- DataController serves /data/playlist.js (with ETag mtime cache)
and /cover.jpg (no-store) so the existing overlays' window.__PLAYLIST
and cover.jpg cache-bust pattern keeps working.
scripts/obs-webapp.supervisord.conf is the supervisord unit; install
to /etc/supervisor.d/obs-webapp.conf.
scripts/rewrite-scene-urls.py is a one-shot tool that rewrites
basic/scenes/Default_Stream_HUD.json from file:// to HTTP URLs.
basic/scenes/Default_Stream_HUD.json.pre-webapp is the rollback
artifact (made with OBS closed; full pre-migration state).
The seven old scene directories, vendor/, and the bash scripts are
still on disk pending visual verification; the next commit will
prune them.
This commit is contained in:
25
webapp/public/.htaccess
Normal file
25
webapp/public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
BIN
webapp/public/audio/static-hum.wav
Normal file
BIN
webapp/public/audio/static-hum.wav
Normal file
Binary file not shown.
576
webapp/public/css/hud.css
Normal file
576
webapp/public/css/hud.css
Normal file
@@ -0,0 +1,576 @@
|
||||
/* ================================================================
|
||||
* OPHI-118 shared HUD stylesheet
|
||||
*
|
||||
* Extracted from the inline <style> blocks of the seven scene
|
||||
* overlays. Each scene's Blade view adds only its layout-specific
|
||||
* rules on top.
|
||||
* ================================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--ink-dim: rgba(232, 232, 224, 0.62);
|
||||
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--hud-edge: rgba(79, 210, 255, 0.85);
|
||||
--hud-glow: rgba(79, 210, 255, 0.40);
|
||||
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
--onair: #5fdc62;
|
||||
--offair: #ff6f5a;
|
||||
|
||||
--term-bg: #04120a;
|
||||
--term-fg: #5fdc62;
|
||||
--term-fg-bright: #97f99a;
|
||||
--term-fg-dim: #2c8d2f;
|
||||
--term-glow: rgba(80, 220, 100, 0.55);
|
||||
--term-edge: rgba(80, 220, 100, 0.45);
|
||||
--term-edge-soft: rgba(80, 220, 100, 0.22);
|
||||
|
||||
--frame: rgba(79, 210, 255, 0.85);
|
||||
--frame-glow: rgba(79, 210, 255, 0.30);
|
||||
|
||||
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
||||
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Opaque overlay (landing/loading/goodbye/music-box) — full-page background.
|
||||
Pages add their own grid-template-rows via inline page CSS. */
|
||||
body.overlay-body {
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
padding: 40px 72px;
|
||||
}
|
||||
|
||||
/* Transparent overlay (game/desktop) — sits on top of capture sources. */
|
||||
body.camera-hud-body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* ───────── HUD top strip ───────── */
|
||||
.hud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hud .group { display: flex; gap: 28px; align-items: center; }
|
||||
.hud > .group:nth-child(1) { justify-self: start; }
|
||||
.hud > .group:nth-child(2) { justify-self: center; }
|
||||
.hud > .group:nth-child(3) { justify-self: end; }
|
||||
.hud .dim { color: var(--hud-dim); }
|
||||
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
|
||||
.hud .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
/* Small upward nudge: flex centers on line-box mid, but caps-only text
|
||||
reads centered around cap-height mid which sits a touch higher. */
|
||||
margin-top: -0.08em;
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.hud--onair .led { background: var(--onair); box-shadow: 0 0 10px var(--onair); }
|
||||
.hud--offair .led {
|
||||
background: var(--offair);
|
||||
box-shadow: 0 0 10px var(--offair);
|
||||
animation: rec-blink 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* The ▮/▯ glyphs in DejaVu Sans Mono are designed centered on x-height,
|
||||
not cap-height, so against all-caps SIGNAL their visual middle sits low.
|
||||
Lift by ~(cap-mid − x-mid) ≈ 0.10em to put the glyph center on cap center. */
|
||||
#signal {
|
||||
display: inline-block;
|
||||
transform: translateY(-0.10em);
|
||||
}
|
||||
|
||||
@keyframes rec-blink {
|
||||
0%, 55% { opacity: 1; }
|
||||
65%, 100% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* ───────── Landing-specific station identifier (top HUD) ───────── */
|
||||
.station {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
white-space: pre;
|
||||
}
|
||||
.station .sep { color: var(--hud-dim); margin: 0 0.45em; letter-spacing: 0.32em; }
|
||||
.station .rig {
|
||||
display: inline-block;
|
||||
color: var(--hud);
|
||||
text-shadow: 0 0 10px rgba(79, 210, 255, 0.55);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.station .rig::before,
|
||||
.station .rig::after {
|
||||
color: var(--hud-dim);
|
||||
font-weight: 700;
|
||||
text-shadow: none;
|
||||
}
|
||||
.station .rig::before { content: '['; margin-right: 0.32em; }
|
||||
.station .rig::after { content: ']'; margin-left: 0.32em; }
|
||||
.station .rig.glitching {
|
||||
color: var(--warn);
|
||||
text-shadow: 0 0 12px rgba(255, 208, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ───────── CRT overlays ─────────
|
||||
Always last in the DOM so they sit above the page content. */
|
||||
#static {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
opacity: 0.045;
|
||||
mix-blend-mode: screen;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.scanlines {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.20) 3px,
|
||||
rgba(0,0,0,0.20) 4px
|
||||
);
|
||||
opacity: 0.5;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
.vignette {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
rgba(0,0,0,0) 45%,
|
||||
rgba(0,0,0,0.60) 100%
|
||||
);
|
||||
}
|
||||
.flicker {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(0,0,0,0);
|
||||
animation: flicker 11s ease-in-out infinite;
|
||||
}
|
||||
@keyframes flicker {
|
||||
0%, 100% { background: rgba(0,0,0,0); }
|
||||
18% { background: rgba(0,0,0,0); }
|
||||
18.3% { background: rgba(0,0,0,0.06); }
|
||||
18.5% { background: rgba(0,0,0,0); }
|
||||
47% { background: rgba(0,0,0,0); }
|
||||
47.3% { background: rgba(0,0,0,0.08); }
|
||||
47.5% { background: rgba(0,0,0,0); }
|
||||
78% { background: rgba(0,0,0,0); }
|
||||
78.4% { background: rgba(0,0,0,0.05); }
|
||||
78.7% { background: rgba(0,0,0,0); }
|
||||
}
|
||||
|
||||
/* ───────── Terminal block ─────────
|
||||
Used by loading, goodbye, music-box (full + nc variant). */
|
||||
.terminal {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
border: 2px solid var(--term-edge);
|
||||
border-radius: 6px;
|
||||
padding: 28px 36px;
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
line-height: 1.55;
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
box-shadow:
|
||||
0 0 60px rgba(80, 220, 100, 0.10),
|
||||
inset 0 0 90px rgba(0, 30, 0, 0.65);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.terminal::after {
|
||||
content: '';
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(0,0,0,0) 0px,
|
||||
rgba(0,0,0,0) 2px,
|
||||
rgba(0,0,0,0.28) 3px,
|
||||
rgba(0,0,0,0.28) 4px
|
||||
);
|
||||
opacity: 0.55;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
/* Terminal output buffer — bottom-anchored, scrolls off the top. */
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
.term-line { white-space: pre-wrap; word-break: break-word; }
|
||||
.term-prompt { color: var(--term-fg-bright); }
|
||||
.term-out { color: var(--term-fg); }
|
||||
.term-dim { color: var(--term-fg-dim); }
|
||||
.term-warn { color: var(--warn); }
|
||||
.term-cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1em;
|
||||
background: var(--term-fg);
|
||||
margin-left: 6px;
|
||||
vertical-align: -0.15em;
|
||||
box-shadow: 0 0 8px var(--term-glow);
|
||||
animation: term-blink 1.05s steps(2) infinite;
|
||||
}
|
||||
@keyframes term-blink { 50% { opacity: 0; } }
|
||||
|
||||
.pin-spacer { height: 14px; }
|
||||
|
||||
.np-line,
|
||||
.term-now-playing { margin-top: 10px; }
|
||||
.np-line .np-arrow,
|
||||
.term-now-playing .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
margin-right: 12px;
|
||||
display: inline-block;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.np-line .np-title,
|
||||
.term-now-playing .np-title { color: var(--term-fg-bright); }
|
||||
.np-line .np-time,
|
||||
.term-now-playing .np-time { color: var(--term-fg-dim); margin-left: 14px; }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
.next-line .next-arrow { color: var(--term-fg-dim); margin-right: 10px; }
|
||||
.next-line .next-label { color: var(--term-fg-dim); margin-right: 6px; }
|
||||
.next-line .next-title { color: var(--term-fg); }
|
||||
.next-line .next-title.empty { color: var(--term-fg-dim); }
|
||||
|
||||
/* ───────── Standby / banner ───────── */
|
||||
.standby { text-align: center; }
|
||||
.standby .big {
|
||||
font-size: 130px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.20em;
|
||||
line-height: 1;
|
||||
text-shadow: 0 0 18px rgba(79, 210, 255, 0.18);
|
||||
animation: standby-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
.standby .sub {
|
||||
margin-top: 30px;
|
||||
font-family: var(--mono);
|
||||
font-size: 26px;
|
||||
letter-spacing: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes standby-pulse {
|
||||
0%, 100% { opacity: 0.90; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
.banner { text-align: center; }
|
||||
.banner .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.banner .title {
|
||||
font-family: var(--mono);
|
||||
font-size: 116px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-shadow:
|
||||
0 0 22px rgba(255, 111, 90, 0.18),
|
||||
0 0 48px rgba(230, 58, 46, 0.10);
|
||||
animation: sign-pulse 3.6s ease-in-out infinite;
|
||||
}
|
||||
.banner.banner--green .title {
|
||||
font-size: 88px;
|
||||
text-shadow:
|
||||
0 0 22px rgba(95, 220, 98, 0.18),
|
||||
0 0 48px rgba(80, 200, 100, 0.10);
|
||||
}
|
||||
.banner .sub {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes sign-pulse {
|
||||
0%, 100% { opacity: 0.88; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* Countdown (loading) */
|
||||
.countdown { text-align: center; }
|
||||
.countdown .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.countdown .digits {
|
||||
font-family: var(--mono);
|
||||
font-size: 132px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.10em;
|
||||
text-indent: 0.10em;
|
||||
line-height: 1;
|
||||
color: var(--ink);
|
||||
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
|
||||
animation: cd-pulse 3s ease-in-out infinite;
|
||||
}
|
||||
.countdown .sub {
|
||||
margin-top: 18px;
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.45em;
|
||||
text-indent: 0.45em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
@keyframes cd-pulse {
|
||||
0%, 100% { opacity: 0.92; }
|
||||
50% { opacity: 1.00; }
|
||||
}
|
||||
.countdown.ready .digits {
|
||||
color: var(--term-fg);
|
||||
text-shadow: 0 0 32px var(--term-glow);
|
||||
letter-spacing: 0.18em;
|
||||
text-indent: 0.18em;
|
||||
}
|
||||
.countdown.ready .sub { color: var(--term-fg-bright); }
|
||||
.countdown.ready .label { color: var(--term-fg-dim); }
|
||||
|
||||
/* ───────── Foot (3-col) ───────── */
|
||||
.foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 19px;
|
||||
color: var(--hud-dim);
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.foot > *:nth-child(1) { justify-self: start; }
|
||||
.foot > *:nth-child(2) { justify-self: center; }
|
||||
.foot > *:nth-child(3) { justify-self: end; }
|
||||
.foot .warn { color: var(--warn); }
|
||||
.foot .onair { color: var(--onair); }
|
||||
.foot .offair { color: var(--offair); }
|
||||
#rig.glitching {
|
||||
color: var(--warn);
|
||||
text-shadow: 0 0 10px rgba(255, 208, 0, 0.4);
|
||||
}
|
||||
|
||||
/* ───────── Status strip (chat help — music-box / nc) ───────── */
|
||||
.status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 28px;
|
||||
font-family: var(--mono);
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--hud-dim);
|
||||
padding: 10px 24px;
|
||||
border-top: 1px solid rgba(79, 210, 255, 0.22);
|
||||
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
|
||||
background: rgba(7, 8, 13, 0.55);
|
||||
}
|
||||
.status-strip .label { color: var(--hud); letter-spacing: 0.32em; }
|
||||
.status-strip .cmd-group { display: inline-flex; gap: 18px; }
|
||||
.status-strip .cmd {
|
||||
color: var(--term-fg-bright);
|
||||
text-shadow: 0 0 6px var(--term-glow);
|
||||
}
|
||||
.status-strip .desc {
|
||||
color: var(--hud-dim);
|
||||
margin-left: 6px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.10em;
|
||||
}
|
||||
|
||||
/* ───────── Camera + screen frame (game / desktop) ───────── */
|
||||
.camera-frame,
|
||||
.screen-frame {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
.camera-frame {
|
||||
/* The four --cam-* vars are auto-synced from the OBS scene item transform.
|
||||
Defaults set in the page <style> for first paint. */
|
||||
top: calc(var(--cam-y) - var(--cam-pad));
|
||||
left: calc(var(--cam-x) - var(--cam-pad));
|
||||
width: calc(var(--cam-w) + var(--cam-pad) * 2);
|
||||
height: calc(var(--cam-h) + var(--cam-pad) * 2);
|
||||
background: rgba(6, 10, 18, 0.85);
|
||||
border: 1.5px solid rgba(79, 210, 255, 0.55);
|
||||
box-shadow:
|
||||
0 0 28px rgba(79, 210, 255, 0.20),
|
||||
inset 0 0 60px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
.screen-frame {
|
||||
top: calc(var(--scr-y) - var(--scr-pad));
|
||||
left: calc(var(--scr-x) - var(--scr-pad));
|
||||
width: calc(var(--scr-w) + var(--scr-pad) * 2);
|
||||
height: calc(var(--scr-h) + var(--scr-pad) * 2);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(79, 210, 255, 0.30);
|
||||
box-shadow: 0 0 18px rgba(79, 210, 255, 0.08);
|
||||
}
|
||||
.camera-frame .tick,
|
||||
.screen-frame .tick {
|
||||
position: absolute;
|
||||
width: 24px; height: 24px;
|
||||
border: 3px solid var(--hud-edge);
|
||||
box-shadow: 0 0 8px var(--hud-glow);
|
||||
}
|
||||
.camera-frame .tick.tl, .screen-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
||||
.camera-frame .tick.tr, .screen-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
||||
.camera-frame .tick.bl, .screen-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
||||
.camera-frame .tick.br, .screen-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
||||
.camera-frame .label,
|
||||
.screen-frame .label {
|
||||
position: absolute;
|
||||
top: 10px; left: 14px;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--hud);
|
||||
letter-spacing: 0.32em;
|
||||
text-shadow: 0 0 4px rgba(7, 8, 13, 0.9);
|
||||
}
|
||||
.camera-frame .placeholder {
|
||||
position: absolute; inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
font-family: var(--mono);
|
||||
color: var(--hud);
|
||||
pointer-events: none;
|
||||
}
|
||||
.camera-frame .placeholder svg { opacity: 0.6; }
|
||||
.camera-frame .placeholder .ph-text {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.45em;
|
||||
color: rgba(79, 210, 255, 0.7);
|
||||
}
|
||||
.camera-frame .placeholder .ph-coords {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.20em;
|
||||
color: var(--hud-dim);
|
||||
}
|
||||
.camera-frame.off,
|
||||
.screen-frame.off { display: none; }
|
||||
|
||||
/* ───────── Camera-HUD bottom status bar (game / desktop) ───────── */
|
||||
.status-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0; right: 0;
|
||||
padding: 22px 72px 26px;
|
||||
background:
|
||||
linear-gradient(
|
||||
to top,
|
||||
rgba(7, 8, 13, 0.96) 0%,
|
||||
rgba(7, 8, 13, 0.96) 65%,
|
||||
rgba(7, 8, 13, 0.50) 90%,
|
||||
rgba(7, 8, 13, 0) 100%
|
||||
);
|
||||
border-top: 1px solid rgba(79, 210, 255, 0.28);
|
||||
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.5);
|
||||
font-family: var(--mono);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||
.status-line + .status-line { margin-top: 8px; }
|
||||
.status-line .sep { color: var(--hud-dim); }
|
||||
.status-line .spacer { flex: 1 1 auto; }
|
||||
|
||||
.live-block {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 18px;
|
||||
}
|
||||
.live-dot {
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
.scene-name,
|
||||
.game-name {
|
||||
color: var(--ink);
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
letter-spacing: 0.10em;
|
||||
}
|
||||
.game-mode {
|
||||
color: var(--hud);
|
||||
font-size: 17px;
|
||||
letter-spacing: 0.18em;
|
||||
}
|
||||
.hud-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--hud);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.08em;
|
||||
text-shadow: 0 0 6px rgba(7, 8, 13, 0.9);
|
||||
}
|
||||
.hud-tag .dim { color: var(--hud-dim); }
|
||||
.meta-line {
|
||||
color: var(--ink-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
.meta-line .icon { color: var(--hud); }
|
||||
.np-block { display: flex; align-items: center; gap: 10px; visibility: hidden; }
|
||||
.np-block.on { visibility: visible; }
|
||||
.np-block .arrow {
|
||||
color: var(--term-fg);
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
text-shadow: 0 0 6px rgba(80, 220, 100, 0.4);
|
||||
}
|
||||
.np-block .title { color: var(--ink); }
|
||||
.np-block .time { color: var(--ink-dim); }
|
||||
0
webapp/public/favicon.ico
Normal file
0
webapp/public/favicon.ico
Normal file
20
webapp/public/index.php
Normal file
20
webapp/public/index.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
28
webapp/public/js/crt-static.js
Normal file
28
webapp/public/js/crt-static.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* OPHI-118 — animated CRT static.
|
||||
*
|
||||
* If <canvas id="static"> isn't on the page (e.g. compact widgets that
|
||||
* skip the noise overlay), this is a no-op.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cv = document.getElementById('static');
|
||||
if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
const W = 320, H = 180;
|
||||
cv.width = W; cv.height = H;
|
||||
const img = ctx.createImageData(W, H);
|
||||
const drawNoise = () => {
|
||||
const d = img.data;
|
||||
for (let i = 0; i < d.length; i += 4) {
|
||||
const v = (Math.random() * 255) | 0;
|
||||
d[i] = d[i+1] = d[i+2] = v;
|
||||
d[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
};
|
||||
drawNoise();
|
||||
// 8 fps — visually almost identical to 12 fps but ~33% less paint work.
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
});
|
||||
})();
|
||||
106
webapp/public/js/hud.js
Normal file
106
webapp/public/js/hud.js
Normal file
@@ -0,0 +1,106 @@
|
||||
/* OPHI-118 — shared HUD helpers used by every overlay.
|
||||
*
|
||||
* Exposes:
|
||||
* - HUD.startClock(el) — UTC ticker into the given element.
|
||||
* - HUD.startSignal(el, opts) — signal-bar fluctuator.
|
||||
* - HUD.scrambleReveal(...) — cyberpunk decode/scramble used by landing.
|
||||
*
|
||||
* Designed to no-op if the target element isn't on the page, so the
|
||||
* base layout can include it unconditionally.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
|
||||
function startClock(el) {
|
||||
if (!el) return;
|
||||
const tick = () => {
|
||||
const d = new Date();
|
||||
el.textContent =
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
};
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
// n thresholds default to the original landing/loading/game distribution:
|
||||
// r < .06 → 2 bars (occasional drop)
|
||||
// r < .70 → 3 bars (typical)
|
||||
// r < .95 → 4 bars
|
||||
// → 5 bars (occasional spike)
|
||||
function startSignal(el, opts) {
|
||||
if (!el) return;
|
||||
const profile = opts?.profile || 'normal';
|
||||
const FILL = '▮', EMPTY = '▯';
|
||||
const draw = n => FILL.repeat(n) + EMPTY.repeat(5 - n);
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
let n;
|
||||
if (profile === 'strong') {
|
||||
n = r < 0.15 ? 3 : r < 0.65 ? 4 : 5;
|
||||
} else if (profile === 'degrading') {
|
||||
n = r < 0.45 ? 1 : r < 0.85 ? 2 : r < 0.97 ? 3 : 0;
|
||||
} else {
|
||||
n = r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5;
|
||||
}
|
||||
el.textContent = draw(n);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// ASCII-only glyph pool — DejaVu Mono renders these at 1ch each, so the
|
||||
// intermediate frames don't shift width.
|
||||
const GLITCH_GLYPHS = '01ABCDEFGHIJKLMNOPQRSTUVWXYZ#@*?!§%';
|
||||
const glyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0];
|
||||
const _scrambleHandles = new WeakMap();
|
||||
|
||||
function scrambleReveal(el, finalText, opts) {
|
||||
opts = opts || {};
|
||||
const { scrambleMs = 360, stepMs = 50, resolveStepMs = 60 } = opts;
|
||||
const prev = _scrambleHandles.get(el);
|
||||
if (prev) { clearInterval(prev.s); clearInterval(prev.r); }
|
||||
const len = finalText.length;
|
||||
if (!len) {
|
||||
el.textContent = '';
|
||||
el.classList.remove('glitching');
|
||||
_scrambleHandles.delete(el);
|
||||
return;
|
||||
}
|
||||
el.classList.add('glitching');
|
||||
const scrambleSteps = Math.max(1, Math.floor(scrambleMs / stepMs));
|
||||
const handle = { s: null, r: null };
|
||||
_scrambleHandles.set(el, handle);
|
||||
let step = 0;
|
||||
handle.s = setInterval(() => {
|
||||
let s = '';
|
||||
for (let i = 0; i < len; i++) s += glyph();
|
||||
el.textContent = s;
|
||||
if (++step < scrambleSteps) return;
|
||||
clearInterval(handle.s); handle.s = null;
|
||||
let resolved = 0;
|
||||
handle.r = setInterval(() => {
|
||||
resolved++;
|
||||
let s = finalText.slice(0, resolved);
|
||||
for (let i = resolved; i < len; i++) s += glyph();
|
||||
el.textContent = s;
|
||||
if (resolved < len) return;
|
||||
clearInterval(handle.r); handle.r = null;
|
||||
el.textContent = finalText;
|
||||
el.classList.remove('glitching');
|
||||
_scrambleHandles.delete(el);
|
||||
}, resolveStepMs);
|
||||
}, stepMs);
|
||||
}
|
||||
|
||||
root.HUD = { pad, startClock, startSignal, scrambleReveal };
|
||||
|
||||
// Auto-bind: every overlay has #clock and #signal in the shared partial.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
startClock(document.getElementById('clock'));
|
||||
const sigEl = document.getElementById('signal');
|
||||
if (sigEl) {
|
||||
const profile = sigEl.dataset.profile || 'normal';
|
||||
startSignal(sigEl, { profile });
|
||||
}
|
||||
});
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
46
webapp/public/js/obs-ws-bootstrap.js
Normal file
46
webapp/public/js/obs-ws-bootstrap.js
Normal file
@@ -0,0 +1,46 @@
|
||||
/* OPHI-118 — OBS WebSocket connection helper with auto-reconnect.
|
||||
*
|
||||
* Most overlays connect to OBS WS to receive `mpd:state` events. This
|
||||
* wraps the connect-then-retry-on-close dance so each page doesn't
|
||||
* repeat it inline.
|
||||
*
|
||||
* OBSWSBootstrap.onState(handler, { onClose, onConnect })
|
||||
* OBSWSBootstrap.connect({ onConnect, onClose })
|
||||
*
|
||||
* Both forms return the underlying OBSWSMini instance via the promise
|
||||
* resolved by onConnect (or directly from connect()).
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
function _connect(opts) {
|
||||
const { onConnect, onClose } = opts || {};
|
||||
if (!root.__OBSWS || !root.OBSWSMini) {
|
||||
onClose && onClose(new Error('vendor/obs-config.js missing'));
|
||||
return;
|
||||
}
|
||||
const obs = new root.OBSWSMini(root.__OBSWS.url, root.__OBSWS.password);
|
||||
obs.connect().then(() => {
|
||||
onConnect && onConnect(obs);
|
||||
}).catch((err) => {
|
||||
onClose && onClose(err);
|
||||
setTimeout(() => _connect(opts), 2500);
|
||||
});
|
||||
obs.addEventListener('close', () => {
|
||||
onClose && onClose(new Error('connection closed'));
|
||||
setTimeout(() => _connect(opts), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function onState(handler, opts) {
|
||||
_connect({
|
||||
onConnect: (obs) => {
|
||||
obs.onCustom('mpd:state', handler);
|
||||
opts?.onConnect && opts.onConnect(obs);
|
||||
},
|
||||
onClose: opts?.onClose,
|
||||
});
|
||||
}
|
||||
|
||||
root.OBSWSBootstrap = { connect: _connect, onState };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
237
webapp/public/js/obs-ws-mini.js
Normal file
237
webapp/public/js/obs-ws-mini.js
Normal file
@@ -0,0 +1,237 @@
|
||||
// Minimal OBS WebSocket v5 client (JSON variant).
|
||||
// Just what the music daemon + loading overlay need:
|
||||
// - connect with optional auth (HMAC-SHA256 challenge)
|
||||
// - call('BroadcastCustomEvent', { eventData: { ... } })
|
||||
// - onCustom(cb) → fires for incoming CustomEvent broadcasts
|
||||
//
|
||||
// Protocol reference:
|
||||
// https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||
//
|
||||
// Exposes: window.OBSWSMini
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// Pure-JS SHA-256 (FIPS 180-4) → base64. Used when crypto.subtle is
|
||||
// unavailable — e.g. OBS's CEF browser source, where file:// URLs do
|
||||
// not grant secure-context status, so window.crypto.subtle is undefined.
|
||||
// Input is treated as a JS string and encoded UTF-8 before hashing.
|
||||
function _sha256b64Pure(str) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let c = str.charCodeAt(i);
|
||||
if (c < 0x80) bytes.push(c);
|
||||
else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
||||
else if (c < 0xd800 || c >= 0xe000) {
|
||||
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
} else {
|
||||
i++;
|
||||
c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
|
||||
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f),
|
||||
0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
}
|
||||
}
|
||||
const bitLen = bytes.length * 8;
|
||||
bytes.push(0x80);
|
||||
while (bytes.length % 64 !== 56) bytes.push(0);
|
||||
const high = Math.floor(bitLen / 0x100000000);
|
||||
const low = bitLen >>> 0;
|
||||
for (let i = 3; i >= 0; i--) bytes.push((high >>> (i * 8)) & 0xff);
|
||||
for (let i = 3; i >= 0; i--) bytes.push((low >>> (i * 8)) & 0xff);
|
||||
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
const K = [
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
||||
];
|
||||
const ROTR = (x, n) => (x >>> n) | (x << (32 - n));
|
||||
const W = new Uint32Array(64);
|
||||
for (let block = 0; block < bytes.length; block += 64) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
W[i] = ((bytes[block + i*4] << 24) |
|
||||
(bytes[block + i*4+1] << 16) |
|
||||
(bytes[block + i*4+2] << 8) |
|
||||
bytes[block + i*4+3]) >>> 0;
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = ROTR(W[i-15], 7) ^ ROTR(W[i-15], 18) ^ (W[i-15] >>> 3);
|
||||
const s1 = ROTR(W[i-2], 17) ^ ROTR(W[i-2], 19) ^ (W[i-2] >>> 10);
|
||||
W[i] = (W[i-16] + s0 + W[i-7] + s1) >>> 0;
|
||||
}
|
||||
let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
|
||||
const S0 = ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22);
|
||||
const mj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + mj) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + t1) >>> 0;
|
||||
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
|
||||
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
|
||||
}
|
||||
let bin = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
bin += String.fromCharCode((H[i]>>>24)&0xff, (H[i]>>>16)&0xff, (H[i]>>>8)&0xff, H[i]&0xff);
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
class OBSWSMini extends EventTarget {
|
||||
constructor(url, password) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.password = password || '';
|
||||
this._reqId = 0;
|
||||
this._pending = new Map();
|
||||
this.ws = null;
|
||||
this.identified = false;
|
||||
}
|
||||
|
||||
async _sha256b64(str) {
|
||||
// Prefer WebCrypto when available (HTTPS/localhost contexts).
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle && crypto.subtle.digest) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
}
|
||||
return _sha256b64Pure(str);
|
||||
}
|
||||
|
||||
async _authString(salt, challenge) {
|
||||
const secret = await this._sha256b64(this.password + salt);
|
||||
return await this._sha256b64(secret + challenge);
|
||||
}
|
||||
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let identified = false;
|
||||
let settled = false;
|
||||
const settle = (fn, val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
fn(val);
|
||||
};
|
||||
|
||||
// Hard timeout — if the server never sends Identified within 10s,
|
||||
// give up so the caller can show an error instead of hanging.
|
||||
const timeout = setTimeout(() => {
|
||||
if (!identified) {
|
||||
try { this.ws?.close(); } catch {}
|
||||
settle(reject, new Error('connect timeout (no Identified within 10s)'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => console.log('[OBSWS] socket open →', this.url);
|
||||
this.ws.onerror = () => settle(reject, new Error('ws error'));
|
||||
this.ws.onclose = (ev) => {
|
||||
this.identified = false;
|
||||
console.log(`[OBSWS] socket closed (code=${ev.code} reason='${ev.reason || ''}' clean=${ev.wasClean})`);
|
||||
if (!identified) {
|
||||
settle(reject, new Error(`closed before Identified (code=${ev.code} reason='${ev.reason || 'none'}')`));
|
||||
}
|
||||
this.dispatchEvent(new Event('close'));
|
||||
};
|
||||
|
||||
this.ws.onmessage = async (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch { return; }
|
||||
console.log('[OBSWS] ←', m.op, m.d?.eventType || m.d?.requestType || '');
|
||||
|
||||
try {
|
||||
if (m.op === 0) { // Hello
|
||||
let auth;
|
||||
if (m.d.authentication) {
|
||||
if (!this.password) {
|
||||
return settle(reject, new Error('server requires password'));
|
||||
}
|
||||
auth = await this._authString(m.d.authentication.salt, m.d.authentication.challenge);
|
||||
}
|
||||
const identifyMsg = {
|
||||
op: 1,
|
||||
d: { rpcVersion: 1, authentication: auth, eventSubscriptions: 0xFFFFFFFF },
|
||||
};
|
||||
console.log('[OBSWS] → 1 (Identify, auth-len=' + (auth?.length || 0) + ')');
|
||||
this.ws.send(JSON.stringify(identifyMsg));
|
||||
} else if (m.op === 2) { // Identified
|
||||
identified = true;
|
||||
this.identified = true;
|
||||
settle(resolve, this);
|
||||
this.dispatchEvent(new Event('identified'));
|
||||
} else if (m.op === 5) { // Event
|
||||
this.dispatchEvent(new CustomEvent('event', { detail: m.d }));
|
||||
if (m.d.eventType === 'CustomEvent') {
|
||||
this.dispatchEvent(new CustomEvent('custom', { detail: m.d.eventData || {} }));
|
||||
}
|
||||
} else if (m.op === 7) { // RequestResponse
|
||||
const p = this._pending.get(m.d.requestId);
|
||||
if (!p) return;
|
||||
this._pending.delete(m.d.requestId);
|
||||
if (m.d.requestStatus && m.d.requestStatus.result) {
|
||||
p.resolve(m.d.responseData || {});
|
||||
} else {
|
||||
p.reject(new Error(m.d.requestStatus?.comment || 'request failed'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OBSWS] message handler error:', e);
|
||||
settle(reject, new Error(`message handler error: ${e.message}`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
call(requestType, requestData) {
|
||||
if (!this.identified) return Promise.reject(new Error('not identified'));
|
||||
const requestId = `r_${++this._reqId}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
this._pending.set(requestId, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({
|
||||
op: 6,
|
||||
d: { requestType, requestId, requestData: requestData || {} },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
if (this._pending.has(requestId)) {
|
||||
this._pending.delete(requestId);
|
||||
reject(new Error('request timeout'));
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: broadcast a namespaced custom event to all OBS WS clients.
|
||||
broadcast(eventType, payload) {
|
||||
return this.call('BroadcastCustomEvent', {
|
||||
eventData: Object.assign({ _type: eventType }, payload || {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: subscribe to incoming custom events. Filtered by _type if given.
|
||||
onCustom(typeOrCb, cb) {
|
||||
const filter = typeof typeOrCb === 'string' ? typeOrCb : null;
|
||||
const fn = filter ? cb : typeOrCb;
|
||||
this.addEventListener('custom', (e) => {
|
||||
const d = e.detail || {};
|
||||
if (filter && d._type !== filter) return;
|
||||
fn(d);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
root.OBSWSMini = OBSWSMini;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
2
webapp/public/robots.txt
Normal file
2
webapp/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Reference in New Issue
Block a user