New full-screen scene (Music Box, Ctrl+M) for music-only foreground use. Built on the same terminal pattern as Goodbye/Loading: typed boot sequence (music --boot --shuffle, [audio] subscribing, library count, queue: shuffle on, standby ✓), scrolling [audio] now: <title> history on track changes, static-HTML pinned block at the bottom (3-up next/then/then + arrow-pulse now-playing line). Below the terminal, a dedicated "CHAT !skip · !queue · !info" status strip — kept outside the scrolling area on purpose so the command help stays visible regardless of terminal contents. Desktop scene now mirrors Game's right-sidebar layout: - Camera moved into top-right at the Game-scene transform (1937,98 × 0.286), always visible (vs. Game which toggles per-session). - New Music Box widget (music-box/widget.html) directly below camera, same width (549 px) so the two pair as one column. 880 px tall to fill the right sidebar down to the canvas bottom. Authored at native pixel size with bounds_type 0 (no stretch) — pinned 3-up next + inline arrow/title/time + chat help; same scrolling [audio] history pattern as the full-screen Music Box. - HUD swapped from the shared Game HUD to a forked desktop/index.html: same top-strip + camera-frame OBS-sync, but no game-name slot (status reads "DESKTOP"), no manifest dependence, no status-bar music widget (lives in the dedicated sidebar source now). Pinned blocks in both Music Box overlays are static HTML so the structure renders even if a script throws — JS only updates text content.
345 lines
11 KiB
HTML
345 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>OPHI-118 / DESKTOP</title>
|
|
<style>
|
|
:root {
|
|
--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;
|
|
--term-fg: #5fdc62;
|
|
--term-fg-dim:#2c8d2f;
|
|
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
|
|
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
|
|
--pad-y: 40px;
|
|
--pad-x: 72px;
|
|
/* Camera frame transform — auto-synced from the Desktop scene's Camera
|
|
item via OBS WS. These defaults are first-paint fallback. */
|
|
--cam-x: 1937px;
|
|
--cam-y: 98px;
|
|
--cam-w: 549px;
|
|
--cam-h: 309px;
|
|
--cam-pad: 10px;
|
|
}
|
|
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html, body {
|
|
width: 100%; height: 100%;
|
|
background: transparent;
|
|
color: var(--ink);
|
|
font-family: var(--display);
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
|
|
/* ───────── Top HUD ───────── */
|
|
.hud {
|
|
position: absolute;
|
|
top: var(--pad-y);
|
|
left: var(--pad-x);
|
|
right: var(--pad-x);
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
align-items: center;
|
|
font-family: var(--mono);
|
|
font-size: 20px;
|
|
font-weight: 700;
|
|
color: var(--hud);
|
|
letter-spacing: 0.08em;
|
|
text-shadow: 0 0 8px rgba(7, 8, 13, 0.9), 0 0 4px rgba(7, 8, 13, 0.9);
|
|
}
|
|
.hud .group { display: flex; gap: 24px; 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: 10px; }
|
|
.hud .led {
|
|
display: inline-block;
|
|
width: 10px; height: 10px;
|
|
border-radius: 50%;
|
|
background: var(--accent);
|
|
box-shadow: 0 0 10px var(--accent);
|
|
margin-top: -0.08em;
|
|
animation: rec-blink 1.6s ease-in-out infinite;
|
|
}
|
|
#signal {
|
|
display: inline-block;
|
|
transform: translateY(-0.10em);
|
|
}
|
|
@keyframes rec-blink {
|
|
0%, 55% { opacity: 1; }
|
|
65%, 100% { opacity: 0.2; }
|
|
}
|
|
|
|
/* ───────── Camera frame (shared logic with game/index.html) ───────── */
|
|
.camera-frame {
|
|
position: absolute;
|
|
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);
|
|
pointer-events: none;
|
|
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);
|
|
}
|
|
.camera-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 { top: -2px; left: -2px; border-right: none; border-bottom: none; }
|
|
.camera-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
|
|
.camera-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
|
|
.camera-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
|
|
.camera-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 { display: none; }
|
|
|
|
/* ───────── Bottom status bar ───────── */
|
|
.status-bar {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0; right: 0;
|
|
padding: 22px var(--pad-x) 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); }
|
|
|
|
.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;
|
|
}
|
|
/* The "DESKTOP" label fills the slot the Game HUD uses for game name. */
|
|
.scene-name {
|
|
color: var(--ink);
|
|
font-weight: 700;
|
|
font-size: 22px;
|
|
letter-spacing: 0.10em;
|
|
}
|
|
.meta-line {
|
|
color: var(--ink-dim);
|
|
font-size: 16px;
|
|
}
|
|
.meta-line .icon { color: var(--hud); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header class="hud">
|
|
<div class="group">
|
|
<span><span class="led"></span>TRANSMISSION</span>
|
|
<span>OPHI-118 // DESKTOP</span>
|
|
</div>
|
|
<div class="group">
|
|
<span class="dim">SIGNAL</span>
|
|
<span id="signal">▮▮▮▯▯</span>
|
|
</div>
|
|
<div class="group">
|
|
<span class="dim">UTC</span>
|
|
<span id="clock">--:--:--</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="camera-frame" id="cam">
|
|
<span class="tick tl"></span>
|
|
<span class="tick tr"></span>
|
|
<span class="tick bl"></span>
|
|
<span class="tick br"></span>
|
|
<span class="label">CAM 01</span>
|
|
<div class="placeholder">
|
|
<svg width="64" height="48" viewBox="0 0 64 48" xmlns="http://www.w3.org/2000/svg" fill="none">
|
|
<rect x="2" y="8" width="44" height="32" rx="2" stroke="#4fd2ff" stroke-width="2"/>
|
|
<path d="M46 18 L60 10 L60 38 L46 30 Z" stroke="#4fd2ff" stroke-width="2" stroke-linejoin="round"/>
|
|
<circle cx="14" cy="14" r="2" fill="#e63a2e"/>
|
|
</svg>
|
|
<span class="ph-text">— WAITING FOR CAMERA —</span>
|
|
<span class="ph-coords">auto-syncs from OBS scene transform</span>
|
|
</div>
|
|
</div>
|
|
|
|
<footer class="status-bar">
|
|
<div class="status-line">
|
|
<div class="live-block">
|
|
<span class="live-dot"></span>
|
|
<span>LIVE</span>
|
|
</div>
|
|
<span class="sep">·</span>
|
|
<span class="scene-name">DESKTOP</span>
|
|
</div>
|
|
<div class="status-line meta-line">
|
|
<span class="icon">◷</span>
|
|
<span class="elapsed" id="elapsed">00:00:00</span>
|
|
<span class="sep">·</span>
|
|
<span class="icon" title="microphone">🎙</span>
|
|
<span style="color: var(--term-fg);">ON</span>
|
|
</div>
|
|
</footer>
|
|
|
|
<script src="../vendor/obs-config.js"></script>
|
|
<script src="../vendor/obs-ws-mini.js"></script>
|
|
|
|
<script>
|
|
'use strict';
|
|
const pad = n => String(n).padStart(2, '0');
|
|
|
|
// ── UTC clock ──
|
|
const clockEl = document.getElementById('clock');
|
|
function tickClock() {
|
|
const d = new Date();
|
|
clockEl.textContent =
|
|
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
|
}
|
|
tickClock(); setInterval(tickClock, 1000);
|
|
|
|
// ── Signal bar fluctuation (steady, like Game) ──
|
|
const sigEl = document.getElementById('signal');
|
|
setInterval(() => {
|
|
const r = Math.random();
|
|
const n = r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5;
|
|
sigEl.textContent = '▮'.repeat(n) + '▯'.repeat(5 - n);
|
|
}, 1500);
|
|
|
|
// ── Stream elapsed time (since page load / scene activation) ──
|
|
const streamStart = Date.now();
|
|
function tickElapsed() {
|
|
const s = Math.floor((Date.now() - streamStart) / 1000);
|
|
const h = Math.floor(s / 3600);
|
|
const mm = Math.floor((s / 60) % 60);
|
|
const ss = s % 60;
|
|
document.getElementById('elapsed').textContent = `${pad(h)}:${pad(mm)}:${pad(ss)}`;
|
|
}
|
|
tickElapsed(); setInterval(tickElapsed, 1000);
|
|
|
|
// ── OBS WS: live camera frame sync ──
|
|
// URL params let you rename the camera source / scene without editing JS:
|
|
// ?camera=Webcam&scene=Desktop
|
|
const params = new URLSearchParams(location.search);
|
|
const CAM_SOURCE = params.get('camera') || 'Camera';
|
|
const CAM_SCENE = params.get('scene') || 'Desktop';
|
|
|
|
const camEl = document.getElementById('cam');
|
|
const rootStyle = document.documentElement.style;
|
|
function setCameraVisible(visible) { camEl.classList.toggle('off', !visible); }
|
|
// Map an OBS sceneItemTransform into the four CSS vars driving the frame.
|
|
// Assumes alignment 5 (top-left) — OBS's default for newly added items.
|
|
function setCameraTransform(t) {
|
|
if (!t) return;
|
|
const w = (t.sourceWidth ?? 0) * (t.scaleX ?? 1);
|
|
const h = (t.sourceHeight ?? 0) * (t.scaleY ?? 1);
|
|
if (!(w > 0 && h > 0)) return;
|
|
rootStyle.setProperty('--cam-x', `${t.positionX}px`);
|
|
rootStyle.setProperty('--cam-y', `${t.positionY}px`);
|
|
rootStyle.setProperty('--cam-w', `${w}px`);
|
|
rootStyle.setProperty('--cam-h', `${h}px`);
|
|
}
|
|
|
|
async function syncCamera(obs) {
|
|
let camItemId = null;
|
|
try {
|
|
const r = await obs.call('GetSceneItemId',
|
|
{ sceneName: CAM_SCENE, sourceName: CAM_SOURCE });
|
|
camItemId = r.sceneItemId;
|
|
const e = await obs.call('GetSceneItemEnabled',
|
|
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
|
setCameraVisible(e.sceneItemEnabled);
|
|
const t = await obs.call('GetSceneItemTransform',
|
|
{ sceneName: CAM_SCENE, sceneItemId: camItemId });
|
|
setCameraTransform(t.sceneItemTransform);
|
|
} catch (err) {
|
|
console.warn(`[desktop] camera sync init failed (${CAM_SCENE}/${CAM_SOURCE}):`, err.message);
|
|
return;
|
|
}
|
|
obs.addEventListener('event', (ev) => {
|
|
const d = ev.detail || {};
|
|
if (d.eventData?.sceneName !== CAM_SCENE
|
|
|| d.eventData?.sceneItemId !== camItemId) return;
|
|
if (d.eventType === 'SceneItemEnableStateChanged') {
|
|
setCameraVisible(d.eventData.sceneItemEnabled);
|
|
} else if (d.eventType === 'SceneItemTransformChanged') {
|
|
setCameraTransform(d.eventData.sceneItemTransform);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function connectOBS() {
|
|
if (!window.__OBSWS || !window.OBSWSMini) return;
|
|
try {
|
|
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
|
await obs.connect();
|
|
obs.addEventListener('close', () => {
|
|
setTimeout(connectOBS, 2500);
|
|
});
|
|
await syncCamera(obs);
|
|
} catch (e) {
|
|
// OBS WS optional — silently retry; defaults stand meanwhile.
|
|
setTimeout(connectOBS, 2500);
|
|
}
|
|
}
|
|
connectOBS();
|
|
</script>
|
|
</body>
|
|
</html>
|