Music daemon + cross-source sync via OBS WebSocket
Music persistence across scene switches by extracting playback into a
separate browser source (music/index.html) — single source instance
shared across scenes via OBS's "Add Existing", so it doesn't restart on
scene change.
Loading and Game overlays subscribe to mpd:state CustomEvents over OBS
WebSocket; the daemon broadcasts at 1 Hz plus on every track change.
File-based command pipeline lets bash control playback without a
WebSocket client of its own:
- vendor/obs-ws-mini.js — minimal WS v5 client with HMAC-SHA256 auth,
hard timeout, close-rejection, and a pure-JS SHA-256 fallback for
CEF file:// origins where crypto.subtle is unavailable.
- setup.sh — generates vendor/obs-config.js from the OBS plugin config;
checks the live socket instead of the (lagging) config file.
- music/index.html — invisible audio worker. Plays through a shuffled
queue with hardened error/stall recovery, polls loading/cmd.js for
bash-issued commands, broadcasts mpd:state via WS. Includes a
diagnostic status block (visible if the source's eye-icon is on).
- loading/index.html — strips local audio playback; subscribes to
mpd:state to drive the now-playing terminal line live. Removes the
redundant trailing prompt; terminal lines anchor at the bottom.
- loading/playlist.sh — rebuilt as subcommand API:
sync (default), skip, prev, pause, resume, status
control commands write loading/cmd.js (id, type, ts), which the
daemon polls every 250ms.
- loading/convert.sh — strips video tracks from .webm music downloads
to audio-only m4a (~95% smaller, ~20× faster decode); fixes the
CPU pegging from CEF decoding 1080p VP9 video for music playback.
- .gitignore — track manifests, ignore generated wrappers + secrets +
the audio cache.
This commit is contained in:
20
.gitignore
vendored
20
.gitignore
vendored
@@ -28,8 +28,28 @@ plugin_config/obs-websocket/
|
||||
# (The JSON itself is tracked: it's the hand-edited source for the wrapper.)
|
||||
landing/telemetry.js
|
||||
|
||||
# Per-session manifest written by loading.sh — ephemeral, not version-controlled.
|
||||
loading/loading.json
|
||||
loading/loading.js
|
||||
|
||||
# Playlist manifest — generated from the audio in /playlist/, machine-local.
|
||||
loading/playlist.json
|
||||
loading/playlist.js
|
||||
|
||||
# Music daemon command file — written by playlist.sh skip/prev/pause/resume.
|
||||
loading/cmd.js
|
||||
|
||||
# Audio files for the loading scene (any format). Kept out of git regardless
|
||||
# of how they got there (yt-dlp, manual copy, etc.).
|
||||
/playlist/
|
||||
|
||||
# OBS WebSocket connection details (port + password) — auto-generated by
|
||||
# setup.sh from your existing plugin_config/obs-websocket/config.json.
|
||||
vendor/obs-config.js
|
||||
|
||||
# Local backups created during edits — keep out of git noise.
|
||||
landing/*.manual
|
||||
loading/*.manual
|
||||
|
||||
# ============================================================
|
||||
# OBS runtime / generated state — no value in version control
|
||||
|
||||
80
loading/convert.sh
Executable file
80
loading/convert.sh
Executable file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env bash
|
||||
# loading/convert.sh — strip video tracks out of audio files in
|
||||
# ~/.config/obs-studio/playlist/ and re-encode to AAC m4a in place.
|
||||
#
|
||||
# WHY: yt-dlp .webm downloads include 1080p video. Chromium's <audio>
|
||||
# element decodes the video stream too (just doesn't render it) — pegs
|
||||
# CPU and stalls playback mid-track. Audio-only m4a is ~95% smaller
|
||||
# and decodes ~20× faster.
|
||||
#
|
||||
# ./convert.sh — convert all video-bearing files
|
||||
# ./convert.sh --dry-run — show what would happen, do nothing
|
||||
#
|
||||
# DO NOT run this while OBS has a playlist track open (between streams
|
||||
# is fine; mid-stream you'll lock the file ffmpeg is reading from).
|
||||
#
|
||||
# After conversion, re-index: ./playlist.sh
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
AUDIO_DIR="$OBS_DIR/playlist"
|
||||
|
||||
DRY=0
|
||||
[[ "${1:-}" == "--dry-run" ]] && DRY=1
|
||||
|
||||
if [[ ! -d "$AUDIO_DIR" ]]; then
|
||||
echo " ✗ $AUDIO_DIR does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ffprobe >/dev/null 2>&1 || ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo " ✗ ffmpeg / ffprobe required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob nocaseglob
|
||||
|
||||
# Files that contain a video stream (and aren't already m4a)
|
||||
declare -a TODO=()
|
||||
for f in "$AUDIO_DIR"/*.{webm,mp4,mkv,mov,avi}; do
|
||||
[[ -f "$f" ]] || continue
|
||||
# Skip yt-dlp intermediate files
|
||||
case "$(basename "$f")" in
|
||||
*.f[0-9]*.*|*.temp.*|*.part) continue ;;
|
||||
esac
|
||||
# Only consider files that actually contain a video stream
|
||||
if ffprobe -v error -select_streams v -show_entries stream=codec_type \
|
||||
-of csv=p=0 "$f" 2>/dev/null | grep -q video; then
|
||||
TODO+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#TODO[@]} -eq 0 ]]; then
|
||||
echo " ✓ nothing to convert (no video-bearing files in $AUDIO_DIR)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "── Converting ${#TODO[@]} file(s) → AAC m4a ──"
|
||||
for f in "${TODO[@]}"; do
|
||||
base="${f%.*}"
|
||||
out="$base.m4a"
|
||||
if [[ -f "$out" ]]; then
|
||||
echo " ⊘ skip (m4a exists): $(basename "$f")"
|
||||
continue
|
||||
fi
|
||||
echo " → $(basename "$f")"
|
||||
if [[ $DRY -eq 1 ]]; then
|
||||
echo " (dry-run: would write $(basename "$out") and remove the source)"
|
||||
continue
|
||||
fi
|
||||
if ffmpeg -nostdin -loglevel error -i "$f" -vn -c:a aac -b:a 192k "$out"; then
|
||||
rm -f "$f"
|
||||
echo " ✓ ${out##*/} ($(du -h "$out" | cut -f1))"
|
||||
else
|
||||
echo " ✗ ffmpeg failed; leaving $(basename "$f") in place" >&2
|
||||
rm -f "$out"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo " → next: ./playlist.sh (re-index for the new filenames)"
|
||||
546
loading/index.html
Normal file
546
loading/index.html
Normal file
@@ -0,0 +1,546 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / PROJECT LOADING</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #07080d;
|
||||
--ink: #e8e8e0;
|
||||
--hud: #4fd2ff;
|
||||
--hud-dim: #2a7fa6;
|
||||
--accent: #e63a2e;
|
||||
--warn: #ffd000;
|
||||
|
||||
--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.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%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: var(--display);
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto auto;
|
||||
padding: 40px 72px;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
/* ───────── 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 .led {
|
||||
display: inline-block;
|
||||
width: 11px; height: 11px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent);
|
||||
margin-right: 12px;
|
||||
vertical-align: middle;
|
||||
animation: rec-blink 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes rec-blink {
|
||||
0%, 55% { opacity: 1; }
|
||||
65%, 100% { opacity: 0.2; }
|
||||
}
|
||||
|
||||
/* ───────── Terminal stage ───────── */
|
||||
.stage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.terminal {
|
||||
width: 64vw;
|
||||
max-width: 1500px;
|
||||
height: 100%;
|
||||
min-height: 380px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--term-bg);
|
||||
border: 1px solid var(--term-edge);
|
||||
border-radius: 6px;
|
||||
padding: 32px 40px;
|
||||
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;
|
||||
}
|
||||
/* New lines anchor at the bottom; older lines overflow off the top
|
||||
(clipped by the parent's overflow:hidden) — true terminal behavior. */
|
||||
#termOutput {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Phosphor scan lines — only inside the terminal panel */
|
||||
.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;
|
||||
}
|
||||
.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-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; } }
|
||||
|
||||
/* Pinned now-playing line — sits at the bottom of term output, updates live */
|
||||
.term-now-playing { margin-top: 10px; }
|
||||
.term-now-playing .np-arrow {
|
||||
color: var(--term-fg-bright);
|
||||
margin-right: 10px;
|
||||
animation: np-pulse 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
.term-now-playing .np-title { color: var(--term-fg-bright); }
|
||||
.term-now-playing .np-time { color: var(--term-fg-dim); margin-left: 12px; }
|
||||
@keyframes np-pulse {
|
||||
0% { opacity: 0.45; }
|
||||
100% { opacity: 1.00; }
|
||||
}
|
||||
|
||||
/* ───────── Big countdown ───────── */
|
||||
.countdown { text-align: center; }
|
||||
.countdown .label {
|
||||
font-family: var(--mono);
|
||||
font-size: 22px;
|
||||
letter-spacing: 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;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
.countdown.ready .sub { color: var(--term-fg-bright); }
|
||||
.countdown.ready .label { color: var(--term-fg-dim); }
|
||||
|
||||
/* ───────── Footer ───────── */
|
||||
.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); }
|
||||
|
||||
/* ───────── Body overlays ───────── */
|
||||
#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); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hud">
|
||||
<div class="group">
|
||||
<span><span class="led"></span>TRANSMISSION</span>
|
||||
<span>OPHI-118 // PROJECT LOAD</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>
|
||||
|
||||
<main class="stage">
|
||||
<div class="terminal">
|
||||
<div id="termOutput"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="countdown" id="cd">
|
||||
<div class="label">— STARTING IN —</div>
|
||||
<div class="digits" id="cdDigits">--:--</div>
|
||||
<div class="sub">— TRANSMISSION INCOMING —</div>
|
||||
</section>
|
||||
|
||||
<footer class="foot">
|
||||
<span>CH 118.0 MHz</span>
|
||||
<span id="rig">— PROJECT MANIFEST LOADED —</span>
|
||||
<span class="warn">▲ ARMED</span>
|
||||
</footer>
|
||||
|
||||
<canvas id="static"></canvas>
|
||||
<div class="scanlines"></div>
|
||||
<div class="vignette"></div>
|
||||
<div class="flicker"></div>
|
||||
|
||||
<!-- Manifest data written by loading.sh — script-tag-loaded global to dodge CEF file:// fetch CORS -->
|
||||
<script src="loading.js"></script>
|
||||
<!-- Playlist manifest written by playlist.sh — same wrapper trick. Used here
|
||||
just for the track count in the terminal output; actual playback runs
|
||||
in the separate Music Daemon source. -->
|
||||
<script src="playlist.js"></script>
|
||||
<!-- OBS WebSocket connection — receives mpd:state broadcasts from the daemon -->
|
||||
<script src="../vendor/obs-config.js"></script>
|
||||
<script src="../vendor/obs-ws-mini.js"></script>
|
||||
|
||||
<script>
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
// ── UTC clock ──
|
||||
const clockEl = document.getElementById('clock');
|
||||
const tickClock = () => {
|
||||
const d = new Date();
|
||||
clockEl.textContent = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
|
||||
};
|
||||
tickClock();
|
||||
setInterval(tickClock, 1000);
|
||||
|
||||
// ── Signal bar fluctuation ──
|
||||
const sigEl = document.getElementById('signal');
|
||||
const drawSig = n => '▮'.repeat(n) + '▯'.repeat(5 - n);
|
||||
setInterval(() => {
|
||||
const r = Math.random();
|
||||
sigEl.textContent = drawSig(r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5);
|
||||
}, 1500);
|
||||
|
||||
// ── Static (CRT noise) ──
|
||||
const cv = document.getElementById('static'), 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();
|
||||
setInterval(drawNoise, 1000 / 8);
|
||||
|
||||
// ── Manifest data (window.__LOADING) ──
|
||||
const data = window.__LOADING || {};
|
||||
const onOff = b => (b ? 'ENABLED' : 'DISABLED');
|
||||
const upper = s => (s || '').toUpperCase();
|
||||
const cdMin = data.countdownMin ?? 5;
|
||||
|
||||
const PROMPT = 'OPHI-118://> ';
|
||||
const lines = [
|
||||
{ kind: 'cmd', text: 'loadproject --manifest' },
|
||||
{ kind: 'out', text: 'initializing transmission envelope...' },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'out', text: `target :: ${upper(data.game) || '—'}` },
|
||||
...(data.subtitle ? [{ kind: 'out', text: `mode :: ${upper(data.subtitle)}` }] : []),
|
||||
{ kind: 'out', text: `camera :: ${onOff(data.camera ?? true)}` },
|
||||
{ kind: 'out', text: `microphone :: ${onOff(data.microphone ?? true)}` },
|
||||
{ kind: 'out', text: `countdown :: ${pad(Math.round(cdMin))}:00` },
|
||||
{ kind: 'gap' },
|
||||
{ kind: 'dim', text: 'all systems nominal' },
|
||||
{ kind: 'out', text: 'READY.' },
|
||||
];
|
||||
|
||||
// ── Build terminal output ──
|
||||
const term = document.getElementById('termOutput');
|
||||
const cursorLine = document.getElementById('termCursorLine');
|
||||
|
||||
// Cap DOM growth: visually old lines have already scrolled off the top
|
||||
// (clipped by overflow:hidden), so dropping them costs nothing.
|
||||
const MAX_TERM_LINES = 200;
|
||||
function pruneTerminal() {
|
||||
while (term.children.length > MAX_TERM_LINES) {
|
||||
const first = term.firstElementChild;
|
||||
if (!first || first === npLine) break;
|
||||
term.removeChild(first);
|
||||
}
|
||||
}
|
||||
|
||||
function append(prompt, text, cls) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
if (prompt) {
|
||||
const p = document.createElement('span');
|
||||
p.className = 'term-prompt';
|
||||
p.textContent = prompt;
|
||||
div.appendChild(p);
|
||||
}
|
||||
const t = document.createElement('span');
|
||||
if (cls) t.className = cls;
|
||||
t.textContent = text;
|
||||
div.appendChild(t);
|
||||
term.appendChild(div);
|
||||
pruneTerminal();
|
||||
return t;
|
||||
}
|
||||
|
||||
// ── Helpers shared between manifest and music sequences ──
|
||||
async function typeCommand(text) {
|
||||
const t = append(PROMPT, '', 'term-out');
|
||||
for (const c of text) {
|
||||
t.textContent += c;
|
||||
await sleep(38);
|
||||
}
|
||||
await sleep(280);
|
||||
}
|
||||
|
||||
// ── Music: passive listener; daemon (music/index.html) plays the audio ──
|
||||
// Track count comes from playlist.js; current track + progress arrive over
|
||||
// OBS WebSocket as 'mpd:state' broadcasts from the daemon.
|
||||
const playlistData = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = playlistData.tracks || [];
|
||||
|
||||
let npLine = null;
|
||||
function ensureNowPlayingLine() {
|
||||
if (npLine) return npLine;
|
||||
npLine = document.createElement('div');
|
||||
npLine.className = 'term-line term-now-playing';
|
||||
npLine.innerHTML =
|
||||
'<span class="np-arrow">▶</span>' +
|
||||
'<span class="np-title">connecting to daemon…</span>' +
|
||||
'<span class="np-time">[--:-- / --:--]</span>';
|
||||
term.appendChild(npLine);
|
||||
return npLine;
|
||||
}
|
||||
function logBeforeNp(text, cls = 'term-out') {
|
||||
if (!npLine) { append('> ', text, cls); return; }
|
||||
const div = document.createElement('div');
|
||||
div.className = 'term-line';
|
||||
const p = document.createElement('span'); p.className = 'term-prompt'; p.textContent = '> ';
|
||||
const t = document.createElement('span'); t.className = cls; t.textContent = text;
|
||||
div.appendChild(p); div.appendChild(t);
|
||||
term.insertBefore(div, npLine);
|
||||
pruneTerminal();
|
||||
}
|
||||
function fmtClock(s) {
|
||||
if (!isFinite(s) || s < 0) return '--:--';
|
||||
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
|
||||
}
|
||||
|
||||
// Connect to OBS WebSocket and listen for daemon broadcasts.
|
||||
let lastTrackIndex = null;
|
||||
async function connectMusic() {
|
||||
if (!window.__OBSWS) {
|
||||
logBeforeNp('[audio] vendor/obs-config.js missing — run setup.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
obs.addEventListener('close', () => {
|
||||
logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim');
|
||||
setTimeout(connectMusic, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:state', (s) => {
|
||||
if (!npLine) ensureNowPlayingLine();
|
||||
// Detect track change (don't log on first state, just initialize).
|
||||
if (lastTrackIndex !== null && s.index !== lastTrackIndex) {
|
||||
logBeforeNp(`[audio] next: ${s.title || '?'}`);
|
||||
}
|
||||
lastTrackIndex = s.index;
|
||||
npLine.querySelector('.np-title').textContent = s.title || '—';
|
||||
npLine.querySelector('.np-time').textContent =
|
||||
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
|
||||
});
|
||||
} catch (e) {
|
||||
logBeforeNp(`[audio] daemon unreachable (${e.message}) — retrying`, 'term-dim');
|
||||
setTimeout(connectMusic, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
async function startMusic() {
|
||||
await sleep(500);
|
||||
await typeCommand('mpd --queue ~/playlists/stream-mix');
|
||||
|
||||
if (allTracks.length === 0) {
|
||||
append('> ', '[audio] no tracks queued — run loading/playlist.sh', 'term-dim');
|
||||
return;
|
||||
}
|
||||
|
||||
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] connecting to daemon on ws://localhost:4455', 'term-out');
|
||||
await sleep(180);
|
||||
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
|
||||
await sleep(220);
|
||||
|
||||
ensureNowPlayingLine();
|
||||
connectMusic();
|
||||
}
|
||||
|
||||
// ── Run sequence: type manifest → READY → music ──
|
||||
(async () => {
|
||||
await sleep(450);
|
||||
for (const ln of lines) {
|
||||
if (ln.kind === 'cmd') {
|
||||
await typeCommand(ln.text);
|
||||
} else if (ln.kind === 'gap') {
|
||||
append('', ' ', '');
|
||||
await sleep(80);
|
||||
} else {
|
||||
append('> ', ln.text, ln.kind === 'dim' ? 'term-dim' : 'term-out');
|
||||
await sleep(170);
|
||||
}
|
||||
}
|
||||
await startMusic();
|
||||
})();
|
||||
|
||||
// ── Live countdown (starts at page load) ──
|
||||
const startMs = Date.now();
|
||||
const totalMs = cdMin * 60 * 1000;
|
||||
const cdEl = document.getElementById('cd');
|
||||
const cdDigits = document.getElementById('cdDigits');
|
||||
const cdLabel = cdEl.querySelector('.label');
|
||||
const cdSub = cdEl.querySelector('.sub');
|
||||
let readyShown = false;
|
||||
|
||||
const tickCountdown = () => {
|
||||
const remaining = Math.max(0, totalMs - (Date.now() - startMs));
|
||||
if (remaining <= 0) {
|
||||
if (!readyShown) {
|
||||
readyShown = true;
|
||||
cdEl.classList.add('ready');
|
||||
cdLabel.textContent = '— STREAM ACTIVE —';
|
||||
cdDigits.textContent = 'READY';
|
||||
cdSub.textContent = '— TRANSMISSION GO —';
|
||||
}
|
||||
return;
|
||||
}
|
||||
const m = Math.floor(remaining / 60000);
|
||||
const s = Math.floor((remaining % 60000) / 1000);
|
||||
cdDigits.textContent = `${pad(m)}:${pad(s)}`;
|
||||
};
|
||||
tickCountdown();
|
||||
setInterval(tickCountdown, 250);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
119
loading/loading.sh
Executable file
119
loading/loading.sh
Executable file
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# Interactive manifest builder for the Project Loading scene.
|
||||
#
|
||||
# ./loading.sh
|
||||
#
|
||||
# Walks you through 5 prompts, writes loading.json + loading.js, then
|
||||
# refresh the Project Loading browser source in OBS. Re-running uses
|
||||
# previous answers as defaults — press Enter to keep them.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
JSON="$DIR/loading.json"
|
||||
JS="$DIR/loading.js"
|
||||
|
||||
# Reads a top-level field from the previous loading.json. Always exits 0;
|
||||
# prints empty string if file/field is missing.
|
||||
prev() {
|
||||
[[ -f "$JSON" ]] || return 0
|
||||
python3 - "$JSON" "$1" <<'PY' 2>/dev/null || true
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1]))
|
||||
v = d.get(sys.argv[2])
|
||||
if v is None: print('')
|
||||
elif isinstance(v, bool): print('y' if v else 'n')
|
||||
else: print(v)
|
||||
except Exception:
|
||||
pass
|
||||
PY
|
||||
}
|
||||
|
||||
ask() {
|
||||
# ask "question" "default" → echoes user input or default if blank
|
||||
local q="$1" def="${2:-}"
|
||||
local prompt=" $q"
|
||||
[[ -n "$def" ]] && prompt+=" [$def]"
|
||||
prompt+=": "
|
||||
local v
|
||||
read -rp "$prompt" v
|
||||
[[ -z "$v" && -n "$def" ]] && v="$def"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
ask_yn() {
|
||||
local v; v=$(ask "$1" "${2:-y}")
|
||||
case "${v,,}" in
|
||||
y|yes|on|true|1) printf 'true' ;;
|
||||
n|no|off|false|0) printf 'false' ;;
|
||||
*) printf 'true' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ask_int() {
|
||||
local v
|
||||
while :; do
|
||||
v=$(ask "$1" "${2:-}")
|
||||
[[ "$v" =~ ^[0-9]+$ ]] && { printf '%s' "$v"; return; }
|
||||
echo " ✗ must be a whole number" >&2
|
||||
done
|
||||
}
|
||||
|
||||
sanitize() {
|
||||
printf '%s' "${1:-}" \
|
||||
| tr -d '"\\' \
|
||||
| tr '\n\r\t' ' ' \
|
||||
| sed 's/ */ /g; s/^ //; s/ $//'
|
||||
}
|
||||
emit_str() { [[ -z "${1:-}" ]] && printf 'null' || printf '"%s"' "$1"; }
|
||||
|
||||
# ── Pull previous answers as defaults ───────────────
|
||||
prev_game=$(prev game)
|
||||
prev_subtitle=$(prev subtitle)
|
||||
prev_count=$(prev countdownMin); [[ -z "$prev_count" ]] && prev_count="5"
|
||||
prev_camera=$(prev camera); [[ -z "$prev_camera" ]] && prev_camera="y"
|
||||
prev_mic=$(prev microphone); [[ -z "$prev_mic" ]] && prev_mic="y"
|
||||
|
||||
echo "── Project Loading manifest ──"
|
||||
echo " (press Enter to keep [defaults])"
|
||||
echo
|
||||
|
||||
game=$(ask "Game" "$prev_game")
|
||||
while [[ -z "$game" ]]; do
|
||||
echo " ✗ Game is required" >&2
|
||||
game=$(ask "Game" "")
|
||||
done
|
||||
|
||||
subtitle=$(ask "Subtitle (mode / episode / note)" "$prev_subtitle")
|
||||
countdown=$(ask_int "Countdown (minutes)" "$prev_count")
|
||||
camera=$(ask_yn "Camera" "$prev_camera")
|
||||
mic=$(ask_yn "Microphone" "$prev_mic")
|
||||
|
||||
game=$(sanitize "$game")
|
||||
subtitle=$(sanitize "$subtitle")
|
||||
|
||||
now_iso=$(date -u +%FT%TZ)
|
||||
tmp="$JSON.tmp.$$"
|
||||
cat > "$tmp" <<JSON
|
||||
{
|
||||
"compiledAt": "$now_iso",
|
||||
"game": $(emit_str "$game"),
|
||||
"subtitle": $(emit_str "$subtitle"),
|
||||
"countdownMin": $countdown,
|
||||
"camera": $camera,
|
||||
"microphone": $mic
|
||||
}
|
||||
JSON
|
||||
|
||||
if ! python3 -m json.tool "$tmp" >/dev/null 2>&1; then
|
||||
echo " ✗ produced invalid JSON, leaving $tmp for inspection" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp" "$JSON"
|
||||
{ printf 'window.__LOADING = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
echo
|
||||
echo " ✓ wrote $JSON"
|
||||
echo " ✓ wrote $JS"
|
||||
echo " → refresh the Project Loading browser source in OBS."
|
||||
176
loading/playlist.sh
Executable file
176
loading/playlist.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bash
|
||||
# loading/playlist.sh — playlist sync + control API for the music daemon.
|
||||
#
|
||||
# Usage:
|
||||
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
||||
# ./playlist.sh sync → explicit sync
|
||||
# ./playlist.sh skip → next track
|
||||
# ./playlist.sh prev → previous track
|
||||
# ./playlist.sh pause → pause playback
|
||||
# ./playlist.sh resume → resume playback
|
||||
# ./playlist.sh status → show last queued command
|
||||
# ./playlist.sh help → this help
|
||||
#
|
||||
# Control commands (skip/prev/pause/resume) write a tiny `cmd.js` that the
|
||||
# Music Daemon polls every 250ms. The daemon picks up the new command,
|
||||
# acts on it, and broadcasts the resulting state via OBS WebSocket so the
|
||||
# loading overlay updates live.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||||
AUDIO_DIR="$OBS_DIR/playlist"
|
||||
JSON="$DIR/playlist.json"
|
||||
JS="$DIR/playlist.js"
|
||||
CMD_JS="$DIR/cmd.js"
|
||||
|
||||
usage() {
|
||||
sed -n 's/^# \?//p' "$0" | head -20
|
||||
}
|
||||
|
||||
# ─────────────── sync ───────────────
|
||||
cmd_sync() {
|
||||
if [[ ! -d "$AUDIO_DIR" ]]; then
|
||||
echo " ✗ $AUDIO_DIR does not exist" >&2
|
||||
echo " Drop your audio files there first." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ffprobe >/dev/null 2>&1; then
|
||||
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "── Indexing $AUDIO_DIR ──"
|
||||
|
||||
local TMP="$JSON.tmp.$$"
|
||||
python3 - "$AUDIO_DIR" "$TMP" <<'PY'
|
||||
import json, subprocess, sys, os, datetime, re
|
||||
|
||||
audio_dir, out = sys.argv[1], sys.argv[2]
|
||||
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
|
||||
|
||||
def ffprobe_meta(path):
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||||
'-of', 'json', path],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if r.returncode != 0: return {}
|
||||
fmt = (json.loads(r.stdout) or {}).get('format', {}) or {}
|
||||
tags = {k.lower(): v for k, v in (fmt.get('tags') or {}).items()}
|
||||
return {
|
||||
'title': tags.get('title'),
|
||||
'artist': tags.get('artist'),
|
||||
'durationSec': float(fmt['duration']) if fmt.get('duration') else None,
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def clean_title(t):
|
||||
if not t: return t
|
||||
# PRIMARY rule: keep only what's before the first vertical bar
|
||||
# (fullwidth | U+FF5C or ASCII |). Everything after is genre/label noise.
|
||||
t = re.sub(r'\s*[||].*$', '', t)
|
||||
# Strip trailing YouTube-ID brackets, e.g. " [-XxZTgMWKV0]"
|
||||
t = re.sub(r'\s*\[[A-Za-z0-9_-]{11}\]\s*$', '', t)
|
||||
# Strip "[NCS Release]" / "(NCS10 Release)" suffix variants
|
||||
t = re.sub(r'\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$', '', t, flags=re.I)
|
||||
return t.strip()
|
||||
|
||||
INTERMEDIATE = re.compile(r'\.(?:f\d+|temp|part)$', re.I)
|
||||
def is_audio_file(name):
|
||||
base, ext = os.path.splitext(name)
|
||||
if ext.lower() not in exts: return False
|
||||
if INTERMEDIATE.search(base): return False
|
||||
return True
|
||||
|
||||
YT_ID_RX = re.compile(r'\[([A-Za-z0-9_-]{11})\]')
|
||||
files = sorted(f for f in os.listdir(audio_dir) if is_audio_file(f))
|
||||
|
||||
tracks, seen_ids = [], set()
|
||||
for fname in files:
|
||||
m = YT_ID_RX.search(fname)
|
||||
yt_id = m.group(1) if m else None
|
||||
if yt_id:
|
||||
if yt_id in seen_ids: continue
|
||||
seen_ids.add(yt_id)
|
||||
path = os.path.join(audio_dir, fname)
|
||||
meta = ffprobe_meta(path)
|
||||
display = clean_title(os.path.splitext(fname)[0])
|
||||
tracks.append({
|
||||
'title': display,
|
||||
'file': f"../playlist/{fname}",
|
||||
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
||||
})
|
||||
|
||||
doc = {
|
||||
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'sourceDir': audio_dir,
|
||||
'trackCount': len(tracks),
|
||||
'tracks': tracks,
|
||||
}
|
||||
with open(out, 'w') as f:
|
||||
json.dump(doc, f, indent=2, ensure_ascii=False)
|
||||
PY
|
||||
|
||||
if ! python3 -m json.tool "$TMP" >/dev/null 2>&1; then
|
||||
echo " ✗ manifest invalid, leaving $TMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$TMP" "$JSON"
|
||||
{ printf 'window.__PLAYLIST = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||||
mv -f "$JS.tmp.$$" "$JS"
|
||||
|
||||
local count
|
||||
count=$(python3 -c "import json; print(json.load(open('$JSON'))['trackCount'])")
|
||||
echo
|
||||
echo " ✓ wrote $JSON ($count tracks)"
|
||||
echo " ✓ wrote $JS"
|
||||
echo " → refresh the Music Daemon browser source in OBS to reload the queue."
|
||||
}
|
||||
|
||||
# ─────────────── control commands ───────────────
|
||||
# Writes a unique-per-call cmd.js that the daemon polls. The id is
|
||||
# nanosecond-precise so the daemon's "did this id change?" check always passes
|
||||
# even on rapid-fire commands.
|
||||
send_cmd() {
|
||||
local type="$1"
|
||||
local id ts
|
||||
id=$(date +%s%N)
|
||||
ts=$(date +%s)
|
||||
printf 'window.__CMD = {"id":%s,"type":"%s","ts":%s};\n' "$id" "$type" "$ts" > "$CMD_JS"
|
||||
echo " → ${type} (id=$id)"
|
||||
}
|
||||
|
||||
cmd_skip() { send_cmd skip; }
|
||||
cmd_prev() { send_cmd prev; }
|
||||
cmd_pause() { send_cmd pause; }
|
||||
cmd_resume() { send_cmd resume; }
|
||||
|
||||
cmd_status() {
|
||||
if [[ -f "$CMD_JS" ]]; then
|
||||
echo "── last queued command ──"
|
||||
cat "$CMD_JS"
|
||||
else
|
||||
echo " (no command file yet — daemon hasn't been signalled this session)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ─────────────── dispatch ───────────────
|
||||
case "${1:-sync}" in
|
||||
sync) cmd_sync ;;
|
||||
skip|next) cmd_skip ;;
|
||||
prev|back) cmd_prev ;;
|
||||
pause) cmd_pause ;;
|
||||
resume|play) cmd_resume ;;
|
||||
status) cmd_status ;;
|
||||
help|-h|--help) usage ;;
|
||||
*)
|
||||
echo " ✗ unknown command: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
228
music/index.html
Normal file
228
music/index.html
Normal file
@@ -0,0 +1,228 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OPHI-118 / Music Daemon</title>
|
||||
<style>
|
||||
/* Daemon page — visible if the source is set visible in OBS, otherwise just
|
||||
a hidden audio worker. The visible state block is for diagnostics: toggle
|
||||
the source's eye-icon ON briefly to read it, OFF when everything works. */
|
||||
html, body { margin: 0; padding: 0; background: #0a0e0a; overflow: hidden; color: #5fdc62;
|
||||
font: 14px/1.45 'DejaVu Sans Mono', 'Consolas', monospace; }
|
||||
#status { padding: 12px 14px; }
|
||||
#status h1 { font-size: 11px; letter-spacing: 0.25em; color: #2c8d2f; margin: 0 0 8px; font-weight: 700; }
|
||||
#status .row { display: flex; gap: 10px; margin: 2px 0; }
|
||||
#status .k { color: #2c8d2f; min-width: 70px; }
|
||||
#status .v { color: #97f99a; word-break: break-all; }
|
||||
#status .err { color: #ff6f5a; }
|
||||
#status .ok { color: #97f99a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="status">
|
||||
<h1>OPHI MUSIC DAEMON</h1>
|
||||
<div class="row"><span class="k">ws</span><span class="v" id="s-ws">init…</span></div>
|
||||
<div class="row"><span class="k">queue</span><span class="v" id="s-queue">—</span></div>
|
||||
<div class="row"><span class="k">now</span><span class="v" id="s-now">—</span></div>
|
||||
<div class="row"><span class="k">audio</span><span class="v" id="s-audio">—</span></div>
|
||||
<div class="row"><span class="k">last cmd</span><span class="v" id="s-cmd">—</span></div>
|
||||
</div>
|
||||
|
||||
<audio id="player" preload="auto"></audio>
|
||||
|
||||
<script src="../vendor/obs-config.js"></script>
|
||||
<script src="../vendor/obs-ws-mini.js"></script>
|
||||
<script src="../loading/playlist.js"></script>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// ─────────────── State ───────────────
|
||||
const data = window.__PLAYLIST || { tracks: [] };
|
||||
const allTracks = data.tracks || [];
|
||||
const audio = document.getElementById('player');
|
||||
audio.volume = 1.0;
|
||||
|
||||
// Fisher-Yates shuffle, fresh every load.
|
||||
function shuffle(a) {
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const queue = shuffle([...allTracks]);
|
||||
let qIdx = 0;
|
||||
let stallTimer = null;
|
||||
let _watchT = 0, _watchAt = Date.now();
|
||||
|
||||
// ─────────────── Visible status block (debug aid) ───────────────
|
||||
// Must come AFTER `queue`/`qIdx` are declared — refreshStatus() reads them.
|
||||
const $ws = document.getElementById('s-ws');
|
||||
const $q = document.getElementById('s-queue');
|
||||
const $n = document.getElementById('s-now');
|
||||
const $a = document.getElementById('s-audio');
|
||||
const $c = document.getElementById('s-cmd');
|
||||
function setWs(text, cls = '') { $ws.textContent = text; $ws.className = 'v ' + cls; }
|
||||
function refreshStatus() {
|
||||
$q.textContent = window.__PLAYLIST
|
||||
? `${allTracks.length} tracks (playlist.js loaded)`
|
||||
: 'playlist.js NOT loaded';
|
||||
const t = allTracks.length ? `#${qIdx + 1}/${allTracks.length} — ${queue[qIdx]?.title || '?'}` : '—';
|
||||
$n.textContent = t;
|
||||
let aState = 'idle';
|
||||
if (audio.paused) aState = 'paused';
|
||||
else if (audio.ended) aState = 'ended';
|
||||
else if (audio.src) aState = `playing (${audio.currentTime.toFixed(0)}s / ${(audio.duration||0).toFixed(0)}s)`;
|
||||
$a.textContent = aState;
|
||||
}
|
||||
setInterval(refreshStatus, 500);
|
||||
refreshStatus();
|
||||
|
||||
function clearStall() { clearTimeout(stallTimer); stallTimer = null; }
|
||||
|
||||
// ─────────────── OBS WebSocket connection ───────────────
|
||||
let obs = null;
|
||||
async function connectOBS() {
|
||||
if (!window.__OBSWS) {
|
||||
setWs('vendor/obs-config.js MISSING — run setup.sh', 'err');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setWs(`connecting → ${window.__OBSWS.url}`);
|
||||
obs = new OBSWSMini(window.__OBSWS.url, window.__OBSWS.password);
|
||||
await obs.connect();
|
||||
setWs('identified ✓', 'ok');
|
||||
obs.addEventListener('close', () => {
|
||||
setWs('closed — retrying in 2s', 'err');
|
||||
setTimeout(connectOBS, 2000);
|
||||
});
|
||||
obs.onCustom('mpd:cmd', (d) => handleCommand(d));
|
||||
broadcastState();
|
||||
} catch (e) {
|
||||
setWs(`connect failed (${e.message}) — retrying`, 'err');
|
||||
setTimeout(connectOBS, 2500);
|
||||
}
|
||||
}
|
||||
connectOBS();
|
||||
|
||||
// ─────────────── Broadcasting ───────────────
|
||||
// Single 'mpd:state' event for everything. Listeners diff `index` to detect
|
||||
// track changes; this means newly-connecting overlays get full state within
|
||||
// ~1s of connecting (no special request/response handshake needed).
|
||||
function broadcastState() {
|
||||
if (!obs || !obs.identified) return;
|
||||
const t = queue[qIdx];
|
||||
obs.broadcast('mpd:state', {
|
||||
index: qIdx,
|
||||
total: queue.length,
|
||||
title: t?.title || null,
|
||||
file: t?.file || null,
|
||||
currentTime: audio.currentTime || 0,
|
||||
duration: audio.duration || t?.durationSec || 0,
|
||||
paused: audio.paused,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// ─────────────── Playback ───────────────
|
||||
function playCurrent() {
|
||||
if (queue.length === 0) return;
|
||||
qIdx = ((qIdx % queue.length) + queue.length) % queue.length;
|
||||
const t = queue[qIdx];
|
||||
clearStall();
|
||||
audio.src = t.file;
|
||||
stallTimer = setTimeout(() => {
|
||||
console.warn('[music] start timeout, skipping', t.title);
|
||||
advance(+1);
|
||||
}, 15000);
|
||||
audio.play().catch(() => {
|
||||
clearStall();
|
||||
console.warn('[music] play() rejected, skipping', t.title);
|
||||
advance(+1);
|
||||
});
|
||||
broadcastState();
|
||||
}
|
||||
|
||||
function advance(direction) {
|
||||
clearStall();
|
||||
if (queue.length === 0) return;
|
||||
qIdx = (qIdx + direction + queue.length) % queue.length;
|
||||
playCurrent();
|
||||
}
|
||||
|
||||
audio.addEventListener('ended', () => advance(+1));
|
||||
audio.addEventListener('error', () => { console.warn('[music] decode error'); advance(+1); });
|
||||
audio.addEventListener('playing', clearStall);
|
||||
|
||||
// Mid-track stall watchdog (currentTime not advancing while expected to play).
|
||||
setInterval(() => {
|
||||
if (audio.paused || audio.ended || !audio.src) {
|
||||
_watchT = audio.currentTime;
|
||||
_watchAt = Date.now();
|
||||
return;
|
||||
}
|
||||
if (Math.abs(audio.currentTime - _watchT) >= 0.05) {
|
||||
_watchT = audio.currentTime;
|
||||
_watchAt = Date.now();
|
||||
} else if (Date.now() - _watchAt > 8000) {
|
||||
console.warn('[music] mid-track stall, skipping');
|
||||
_watchT = 0; _watchAt = Date.now();
|
||||
advance(+1);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Periodic state broadcast — 1 Hz is enough for the visible time display
|
||||
// and ensures any newly-connecting overlay catches up within a second.
|
||||
setInterval(broadcastState, 1000);
|
||||
|
||||
// ─────────────── Command sources ───────────────
|
||||
// 1) Bash CLI: writes loading/cmd.js → polled here via cache-busted <script>.
|
||||
// First poll after page-load ignores the existing command (it was already
|
||||
// consumed in the previous session).
|
||||
let lastCmdId = null;
|
||||
let firstCmdPoll = true;
|
||||
function pollCmd() {
|
||||
const old = document.getElementById('cmd-poll');
|
||||
if (old) old.remove();
|
||||
const s = document.createElement('script');
|
||||
s.id = 'cmd-poll';
|
||||
s.src = '../loading/cmd.js?t=' + Date.now();
|
||||
s.onload = () => {
|
||||
const cmd = window.__CMD;
|
||||
if (!cmd) return;
|
||||
if (firstCmdPoll) { lastCmdId = cmd.id; firstCmdPoll = false; return; }
|
||||
if (cmd.id !== lastCmdId) {
|
||||
lastCmdId = cmd.id;
|
||||
handleCommand(cmd);
|
||||
}
|
||||
};
|
||||
s.onerror = () => { firstCmdPoll = false; }; // file doesn't exist yet — fine
|
||||
document.body.appendChild(s);
|
||||
}
|
||||
setInterval(pollCmd, 250);
|
||||
pollCmd();
|
||||
|
||||
// 2) OBS WebSocket: any source can send mpd:cmd via OBSWSMini.broadcast().
|
||||
function handleCommand(cmd) {
|
||||
const type = (cmd?.type || '').toLowerCase();
|
||||
$c.textContent = `${type} @ ${new Date().toLocaleTimeString()}`;
|
||||
switch (type) {
|
||||
case 'skip': advance(+1); break;
|
||||
case 'prev': advance(-1); break;
|
||||
case 'pause': audio.pause(); broadcastState(); break;
|
||||
case 'resume': audio.play().catch(() => {}); broadcastState(); break;
|
||||
default:
|
||||
console.warn('[music] unknown command:', type);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── Boot ───────────────
|
||||
if (queue.length > 0) {
|
||||
playCurrent();
|
||||
} else {
|
||||
console.warn('[music] no tracks — run loading/playlist.sh');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
56
setup.sh
Executable file
56
setup.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time setup: builds vendor/obs-config.js from your existing OBS WebSocket
|
||||
# plugin config so the overlay/daemon pages can connect.
|
||||
#
|
||||
# ./setup.sh
|
||||
#
|
||||
# Re-run if you change the WebSocket port or password in OBS.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SRC="$DIR/plugin_config/obs-websocket/config.json"
|
||||
OUT="$DIR/vendor/obs-config.js"
|
||||
|
||||
if [[ ! -f "$SRC" ]]; then
|
||||
echo " ✗ $SRC not found." >&2
|
||||
echo " Open OBS once with the obs-websocket plugin loaded so it generates the config." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull port + password out of the JSON
|
||||
read -r PORT PASS ENABLED <<<"$(python3 -c "
|
||||
import json, sys
|
||||
d = json.load(open('$SRC'))
|
||||
print(d.get('server_port', 4455), d.get('server_password', ''), str(d.get('server_enabled', False)).lower())
|
||||
")"
|
||||
|
||||
mkdir -p "$DIR/vendor"
|
||||
cat > "$OUT" <<JS
|
||||
// Auto-generated by setup.sh — gitignored. Reflects the current contents of
|
||||
// plugin_config/obs-websocket/config.json. Re-run setup.sh if it changes.
|
||||
window.__OBSWS = {
|
||||
url: 'ws://localhost:$PORT',
|
||||
password: '$PASS',
|
||||
};
|
||||
JS
|
||||
|
||||
echo " ✓ wrote $OUT"
|
||||
echo " url = ws://localhost:$PORT"
|
||||
if [[ -z "$PASS" ]]; then
|
||||
echo " password = (none)"
|
||||
else
|
||||
echo " password = (set, ${#PASS} chars)"
|
||||
fi
|
||||
|
||||
# Check the live socket, not the config file — OBS lags writes to plugin_config
|
||||
# until shutdown, so server_enabled in JSON often disagrees with reality.
|
||||
echo
|
||||
if ss -lnt 2>/dev/null | grep -q ":$PORT "; then
|
||||
echo " ✓ OBS WebSocket server is listening on :$PORT"
|
||||
elif nc -z localhost "$PORT" 2>/dev/null; then
|
||||
echo " ✓ OBS WebSocket server is listening on :$PORT"
|
||||
else
|
||||
echo " ⚠ Nothing listening on :$PORT yet."
|
||||
echo " In OBS: Tools → WebSocket Server Settings → ✅ Enable WebSocket server → OK"
|
||||
echo " (config.json may already say 'enabled: $ENABLED' — that file lags actual state)"
|
||||
fi
|
||||
237
vendor/obs-ws-mini.js
vendored
Normal file
237
vendor/obs-ws-mini.js
vendored
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);
|
||||
Reference in New Issue
Block a user