webapp: archive pre-migration scene dirs + bash scripts, update docs

Moves the seven HTML scene dirs (landing/, loading/, game/, desktop/,
goodbye/, music-box/, music/) and the superseded bash helpers (setup.sh,
loading.sh, playlist.sh, telemetry.sh) into archived/ rather than deleting.
The Laravel webapp/ replaces all of them; archived/README.md spells out
the rollback procedure.

Also:
 - rewrite the relevant sections of CLAUDE.md so it points at webapp/
   blade views, the Artisan commands, and the supervisord lifecycle
   (`supervisorctl restart obs-webapp` after route / controller / .env
   changes; Blade view edits are still safe-while-running via OBS's
   Refresh cache).
 - extend scripts/deploy-rig.sh to install php + composer + supervisor,
   run `composer install`, copy obs-webapp.supervisord.conf into
   /etc/supervisor.d/, start the program, and call `php artisan
   rig:setup` + `rig:telemetry --collect` instead of the old bash.
 - .gitignore catches the generated machine-local files that came along
   when the old scene dirs moved (telemetry.js, loading.json, playlist.js,
   cmd.js, obs-config.js).
 - daemon.blade.php is now passive — listens to mpd:state but does not
   play audio or broadcast its own queue, so it stops fighting with
   bridges/mpd-state.py (the post-browser-daemon-migration source of
   truth for mpd:state).
 - nc.blade.php overrides .terminal { overflow: hidden } from hud.css
   so the `┤ TERMINAL ├` and `┤ NOW PLAYING ├` pane-tabs stick above
   the pane border instead of being clipped.
This commit is contained in:
Jakub Zych
2026-05-21 13:08:49 +02:00
parent 059f069ef4
commit 0b4f121a92
23 changed files with 229 additions and 156 deletions

14
.gitignore vendored
View File

@@ -52,6 +52,20 @@ webapp/storage/data/*.manual
/webapp/storage/framework/views/
/webapp/bootstrap/cache/*.php
# archived/ — old machine-local generated files that came along when the
# legacy scene dirs were moved. The hand-authored HTML / JSON sources are
# tracked; only the script-generated wrappers and runtime state aren't.
archived/scenes/landing/telemetry.js
archived/scenes/landing/*.manual
archived/scenes/loading/loading.json
archived/scenes/loading/loading.js
archived/scenes/loading/playlist.json
archived/scenes/loading/playlist.js
archived/scenes/loading/cmd.js
archived/scenes/loading/*.manual
archived/scenes/loading/playlist/
archived/vendor/obs-config.js
# ============================================================
# OBS runtime / generated state — no value in version control
# ============================================================

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Current state (read this first)
Active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), six custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target).
Active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), a Laravel webapp (`webapp/`) that serves the scene overlays over `http://127.0.0.1:1118/` under supervisord, an MPD-based audio architecture with `bridges/mpd-state.py` re-broadcasting MPD state as `mpd:state` events over OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target).
## What this directory is
@@ -32,38 +32,59 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p
- `plugin_manager/modules.json` — third-party plugins OBS loads.
- `logs/`, `profiler_data/`, `.sentinel/` — runtime state.
**Custom overlays (browser sources rendering local HTML — safe to edit while OBS is up):**
- `landing/` — Fallout-style "PLEASE STAND BY" overlay + telemetry + 12 s static-hum loop.
- `loading/` — terminal-style Project Loading overlay + per-session manifest + flat playlist index + legacy `cmd.js`.
- `game/index.html` — Game scene HUD (TRANSMISSION ID, signal, clock, camera frame, status bar). Live-syncs camera transform via OBS WS.
- `desktop/index.html` — Desktop scene HUD, forked from Game (no game-name slot, no music widget — that lives below the camera as a separate source).
- `goodbye/index.html` — sign-off overlay.
- `music-box/index.html` — full-screen Music Box terminal (scrolling `[audio]` log + pinned 3-up next + chat help strip).
- `music-box/widget.html` — compact 549×880 sidebar widget for the Desktop scene. **Authored at native pixel size** (`bounds_type: 0`, no stretch).
- `music/index.html` — legacy "Music Daemon" browser source.
- `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client. `vendor/obs-config.js` is the auto-generated WS connection config (gitignored).
**Scene overlays (`webapp/` — Laravel app served on 127.0.0.1:1118 by supervisord):**
- `webapp/resources/views/overlays/landing.blade.php` — Fallout-style "PLEASE STAND BY" overlay + telemetry rotator.
- `webapp/resources/views/overlays/loading.blade.php` — terminal-style Project Loading overlay + countdown.
- `webapp/resources/views/overlays/game.blade.php` — Game HUD (camera-frame OBS-WS sync, status bar).
- `webapp/resources/views/overlays/desktop.blade.php` — Desktop HUD (camera + screen-capture frames).
- `webapp/resources/views/overlays/goodbye.blade.php` — sign-off terminal + "OFFLINE FOR" counter.
- `webapp/resources/views/overlays/music/box.blade.php` — full-screen Music Box terminal.
- `webapp/resources/views/overlays/music/widget.blade.php` — compact 549×880 widget (`bounds_type: 0`, no stretch).
- `webapp/resources/views/overlays/music/cover.blade.php` — album-art widget (`?bars=0` for compact mode).
- `webapp/resources/views/overlays/music/nc.blade.php` — ncurses-styled 2-pane Music Box.
- `webapp/resources/views/overlays/music/daemon.blade.php` — passive diagnostic; playback is handled by MPD + `bridges/mpd-state.py`.
- `webapp/public/css/hud.css` — extracted shared HUD chrome, scanlines, terminal styling, pulse keyframes.
- `webapp/public/js/{hud,crt-static,obs-ws-bootstrap,obs-ws-mini}.js` — shared JS; `obs-config.js` is gitignored, generated by `php artisan rig:setup`.
- Routes + controllers under `webapp/app/Http/Controllers/`. See `routes/web.php` for the URL → handler table.
**Artisan commands (replace the old bash helpers — see `archived/scripts/`):**
- `php artisan rig:setup` — generate `webapp/public/js/obs-config.js` + `OBS_WS_*` env from `plugin_config/obs-websocket/config.json`.
- `php artisan rig:telemetry [--collect]` — refresh hardware/OBS-profile snapshot at `webapp/storage/data/telemetry.json`.
- `php artisan rig:loading` — interactive manifest builder; updates Twitch channel via Helix (no Python shell-out).
- `php artisan rig:playlist` — index audio dirs with ffprobe → `webapp/storage/data/playlist.json`.
- `php artisan rig:cmd <skip|prev|pause|resume>` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`).
**Audio + automation surfaces:**
- `bridges/mpd-state.py` — MPD → OBS WS state bridge (`obs-mpd-bridge.service`).
- `twitch-bot/bot.py` — Twitch chat bot for `!skip` / `!queue` / `!info` + optional Mattermost going-live notifier (`obs-twitch-bot.service`).
- `twitch-bot/{search-game,set-channel,_twitch}.py` — Helix helpers used by `scripts/loading.sh`.
- `bridges/mpd-state.py` — MPD → OBS WS state bridge (`obs-mpd-bridge.service`). Writes `bridges/cover.jpg`. Authoritative source of `mpd:state` events.
- `twitch-bot/bot.py` — Twitch chat bot for `!skip` / `!queue` / `!info`. Talks to MPD directly, not via OBS WS.
- `twitch-bot/{search-game,set-channel,_twitch}.py` — Helix helpers (still present; `rig:loading` no longer needs them — it uses Guzzle directly).
- `twitch-bot/.env*` — secrets (per-rig, never copy).
**Scripts (`.sh` lives only here):**
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig.
- `scripts/setup.sh` — generate `vendor/obs-config.js` after a WS password change.
- `scripts/telemetry.sh` — refresh `landing/telemetry.json` + wrap to `.js`.
- `scripts/loading.sh` — interactive `loading.json` builder; pushes title/category to Twitch.
- `scripts/playlist.sh` — playlist sync + legacy daemon control.
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig (now also installs the supervisord program + runs `composer install`).
- `scripts/obs-webapp.supervisord.conf` — supervisord program file; install to `/etc/supervisor.d/obs-webapp.conf`.
- `scripts/rewrite-scene-urls.py` — one-shot tool that rewrote `basic/scenes/Default_Stream_HUD.json` from `file://` to `http://127.0.0.1:1118/...` URLs during migration. Idempotent.
- `scripts/convert.sh`, `scripts/clean.sh` — yt-dlp `.webm` → AAC `.m4a` re-encode + audit.
**Reference / data:** `playlist/` (gitignored library), `profile/` (rig spec docs), `station/` (git submodule for the public rig-spec page).
**Reference / archive:** `archived/` (pre-migration scenes + bash scripts + `vendor/`, kept for rollback — see `archived/README.md`). `playlist/` (gitignored audio library). `profile/` (rig spec docs). `station/` (submodule for the public rig-spec page).
## How OBS reaches the overlays — supervisord + Laravel
OBS browser sources point at `http://127.0.0.1:1118/<route>` (rewritten from the old `file://...` URLs by `scripts/rewrite-scene-urls.py`). The Laravel app runs under supervisord:
```
sudo supervisorctl status obs-webapp # → RUNNING
sudo supervisorctl restart obs-webapp # after route / controller / .env changes
tail -F webapp/storage/logs/supervisord.{out,err}.log
```
The program file is `scripts/obs-webapp.supervisord.conf`; the installed copy is at `/etc/supervisor.d/obs-webapp.conf` (root-owned).
## Critical gotcha: OBS overwrites on exit
OBS rewrites `basic/scenes/<collection>.json`, `global.ini`, and `user.ini` on shutdown (and periodically). **Close OBS before editing these files**, or the next OBS exit will clobber the edit. After editing, re-launch OBS to load the change.
**HTML overlays under `landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/` are safe to edit while OBS is up** — CEF re-reads them on demand. Apply the change with right-click source → **Refresh cache** in OBS, no restart needed.
**Blade views under `webapp/resources/views/overlays/` are safe to edit while OBS is up** — CEF re-fetches on right-click source → **Refresh cache** in OBS, no restart needed. **Controller / route / `.env` changes need a supervisord restart** (`sudo supervisorctl restart obs-webapp`) — only then will the browser sources see the new behavior on next Refresh cache. CSS and JS under `webapp/public/` are pure static files; no restart needed, just Refresh cache.
**Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use:
```bash

52
archived/README.md Normal file
View File

@@ -0,0 +1,52 @@
# archived/
Pre-`webapp/` artefacts retained for reference and rollback.
## scenes/
The seven hand-authored HTML scene overlays that lived at the repo root
before the Laravel migration:
- `landing/` — "PLEASE STAND BY" with telemetry rotator
- `loading/` — project-load terminal + countdown + manifest
- `game/` — game HUD with camera-frame OBS-WS sync
- `desktop/` — desktop HUD (camera + screen frames)
- `goodbye/` — sign-off terminal + "OFFLINE FOR" counter
- `music/` — legacy browser-side music daemon (HTML5 `<audio>`)
- `music-box/` — full HUD, widget, cover, and nc variant
Each `index.html` is a self-contained file with inline CSS+JS. The
corresponding Blade view lives in
`webapp/resources/views/overlays/<scene>.blade.php` (and `music/box.blade.php`,
`music/widget.blade.php`, `music/cover.blade.php`, `music/nc.blade.php`,
`music/daemon.blade.php` for the music sub-tree).
## scripts/
Bash helpers that wrote `window.__X` global JS files. Replaced by Artisan:
| Old | New |
|---------------------------|------------------------------------|
| `scripts/setup.sh` | `php artisan rig:setup` |
| `scripts/loading.sh` | `php artisan rig:loading` |
| `scripts/playlist.sh` | `php artisan rig:playlist` |
| `scripts/telemetry.sh` | `php artisan rig:telemetry` |
| `scripts/playlist.sh skip`| `php artisan rig:cmd skip` (or `curl -X POST http://127.0.0.1:1118/cmd/skip`) |
## vendor/
- `obs-ws-mini.js` — moved to `webapp/public/js/obs-ws-mini.js`.
- `obs-config.js` — moved to `webapp/public/js/obs-config.js` (still
generated by `rig:setup`).
## Rollback
1. Close OBS (`flatpak kill com.obsproject.Studio`).
2. Restore the scene JSON:
```
cp basic/scenes/Default_Stream_HUD.json.pre-webapp \
basic/scenes/Default_Stream_HUD.json
```
3. Move directories back from `archived/scenes/` to the repo root,
`archived/vendor/` back to `vendor/`, and `archived/scripts/*.sh`
back to `scripts/`.
4. Re-run `bash scripts/setup.sh` to regenerate `vendor/obs-config.js`
at the old path.
5. Stop the webapp: `sudo supervisorctl stop obs-webapp`.
6. Restart OBS.

View File

@@ -3719,7 +3719,7 @@
"saved_projectors": [],
"preview_locked": false,
"scaling_enabled": false,
"scaling_level": -14,
"scaling_level": -5,
"scaling_off_x": 0.0,
"scaling_off_y": 0.0,
"modules": {

View File

@@ -47,7 +47,7 @@ RIG_NAME_TC="${RIG_NAME^}" # title-case: ignia → Ignia
# ── output helpers ─────────────────────────────────────────────────────────
PHASE=0
TOTAL=9
TOTAL=10
step() { PHASE=$((PHASE+1)); printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$PHASE" "$TOTAL" "$*"; }
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
skip() { printf ' \033[90m·\033[0m %s\n' "$*"; }
@@ -95,6 +95,8 @@ PACMAN_PKGS=(
mpd mpc
avahi nss-mdns
fontconfig
php composer
supervisor
)
if command -v pacman >/dev/null; then
@@ -558,17 +560,87 @@ PY
fi
fi
if [[ -f "$OBS_DIR/vendor/obs-config.js" ]]; then
ok "vendor/obs-config.js present"
if [[ -f "$OBS_DIR/webapp/public/js/obs-config.js" ]]; then
ok "webapp/public/js/obs-config.js present"
else
if would "bash scripts/setup.sh"; then
bash "$DIR/setup.sh" || warn "setup.sh failed — re-run after OBS WS port is reachable"
if would "php artisan rig:setup"; then
( cd "$OBS_DIR/webapp" && php artisan rig:setup ) \
|| warn "rig:setup failed — re-run after OBS WS port is reachable"
fi
fi
fi
# ────────────────────────────────────────────────────────────────────────────
# Phase 9 — secrets stub + final report
# Phase 9 — webapp (Laravel) + supervisord
# ────────────────────────────────────────────────────────────────────────────
step "Webapp (Laravel) + supervisord program"
if [[ -d "$OBS_DIR/webapp" ]]; then
if [[ -d "$OBS_DIR/webapp/vendor" ]] && [[ -f "$OBS_DIR/webapp/vendor/autoload.php" ]]; then
ok "webapp/vendor present (composer install already ran)"
else
if would "composer install in webapp/"; then
( cd "$OBS_DIR/webapp" && composer install --no-dev --optimize-autoloader ) \
|| warn "composer install failed — re-run manually if php/composer were just installed"
ok "composer install complete"
fi
fi
if [[ -f "$OBS_DIR/webapp/.env" ]]; then
ok "webapp/.env present"
elif [[ -f "$OBS_DIR/webapp/.env.example" ]]; then
if would "cp .env.example .env + php artisan key:generate"; then
cp "$OBS_DIR/webapp/.env.example" "$OBS_DIR/webapp/.env"
( cd "$OBS_DIR/webapp" && php artisan key:generate ) >/dev/null
ok "seeded webapp/.env from .env.example"
fi
fi
if [[ -f /etc/supervisor.d/obs-webapp.conf ]]; then
ok "/etc/supervisor.d/obs-webapp.conf already installed"
else
if would "sudo install scripts/obs-webapp.supervisord.conf → /etc/supervisor.d/"; then
sudo install -m 644 "$DIR/obs-webapp.supervisord.conf" /etc/supervisor.d/obs-webapp.conf
ok "installed /etc/supervisor.d/obs-webapp.conf"
fi
fi
if systemctl is-enabled supervisord >/dev/null 2>&1; then
ok "supervisord enabled"
else
if would "sudo systemctl enable --now supervisord"; then
sudo systemctl enable --now supervisord
ok "enabled supervisord"
fi
fi
if (( ! CHECK_ONLY )); then
sudo supervisorctl reread >/dev/null 2>&1 || true
sudo supervisorctl update >/dev/null 2>&1 || true
if sudo supervisorctl status obs-webapp 2>/dev/null | grep -q RUNNING; then
ok "obs-webapp program RUNNING"
else
if would "sudo supervisorctl start obs-webapp"; then
sudo supervisorctl start obs-webapp 2>&1 | sed 's/^/ /' || \
warn "obs-webapp didn't start — check storage/logs/supervisord.err.log"
fi
fi
fi
if (( ! CHECK_ONLY )); then
sleep 1
if curl -fsS -o /dev/null http://127.0.0.1:1118/landing; then
ok "http://127.0.0.1:1118/landing responding"
else
warn "127.0.0.1:1118/landing not reachable yet — check obs-webapp status"
fi
fi
else
warn "webapp/ missing — clone the repo properly first"
fi
# ────────────────────────────────────────────────────────────────────────────
# Phase 10 — secrets stub + final report
# ────────────────────────────────────────────────────────────────────────────
step "Secrets stub + final report"
@@ -590,9 +662,9 @@ stub_env "$OBS_DIR/twitch-bot/.env.ophi118"
# Refresh telemetry so the rig name is correct on this machine.
if (( ! CHECK_ONLY )); then
if would "scripts/telemetry.sh --collect --no-review (rig snapshot)"; then
bash "$DIR/telemetry.sh" --collect --no-review || \
warn "telemetry.sh --collect failed — re-run interactively to review"
if would "php artisan rig:telemetry --collect (rig snapshot)"; then
( cd "$OBS_DIR/webapp" && php artisan rig:telemetry --collect ) || \
warn "rig:telemetry failed — re-run interactively to review"
ok "telemetry refreshed (rig=$RIG_NAME_TC)"
fi
fi
@@ -620,7 +692,7 @@ cat <<DONE
systemctl --user restart obs-twitch-bot.service
5. Review telemetry interactively:
bash scripts/telemetry.sh --collect
cd webapp && php artisan rig:telemetry --collect
6. Launch OBS:
flatpak run com.obsproject.Studio
@@ -633,6 +705,8 @@ cat <<DONE
Sanity checks:
pactl list short sinks | grep -E 'mpd_stream|discord_stream'
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
sudo supervisorctl status obs-webapp
curl -fsS http://127.0.0.1:1118/landing | head -1
mpc status
ss -tlnp | grep 4455

View File

@@ -4,6 +4,11 @@
<meta charset="utf-8">
<title>OPHI-118 / Music Daemon</title>
<style>
/* Diagnostic status page. Music is actually played by MPD (system service)
and broadcast over OBS WS by bridges/mpd-state.py. This page no longer
plays audio or broadcasts mpd:state those would conflict with the
bridge. It only listens to incoming mpd:state events so you can sanity-
check the bridge from a single browser source. */
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; }
@@ -13,66 +18,36 @@
#status .v { color: #97f99a; word-break: break-all; }
#status .err { color: #ff6f5a; }
#status .ok { color: #97f99a; }
#status .dim { color: #2c8d2f; }
</style>
</head>
<body>
<div id="status">
<h1>OPHI MUSIC DAEMON</h1>
<h1>OPHI MUSIC DAEMON (passive)</h1>
<div class="row"><span class="k">role</span><span class="v dim">listener playback handled by mpd + bridges/mpd-state.py</span></div>
<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">queue</span><span class="v" id="s-queue"></span></div>
<div class="row"><span class="k">elapsed</span><span class="v" id="s-elapsed"></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>
@include('partials.obs-ws-scripts')
<script src="{{ asset('data/playlist.js') }}"></script>
<script>
'use strict';
const data = window.__PLAYLIST || { tracks: [] };
const allTracks = data.tracks || [];
const audio = document.getElementById('player');
audio.volume = 1.0;
const $ws = document.getElementById('s-ws');
const $now = document.getElementById('s-now');
const $queue = document.getElementById('s-queue');
const $elapsed = document.getElementById('s-elapsed');
const $cmd = document.getElementById('s-cmd');
const pad = n => String(n).padStart(2, '0');
const fmtClock = s => (!isFinite(s) || s < 0)
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
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();
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; }
function setWs(text, cls = '') { $ws.textContent = text; $ws.className = 'v ' + cls; }
let obs = null;
async function connectOBS() {
@@ -89,92 +64,24 @@
setWs('closed — retrying in 2s', 'err');
setTimeout(connectOBS, 2000);
});
obs.onCustom('mpd:cmd', (d) => handleCommand(d));
broadcastState();
obs.onCustom('mpd:state', (s) => {
$now.textContent = s.title ? `${s.title}${s.artist ? ` ${s.artist}` : ''}` : '—';
const total = Number(s.total) || 0;
const idx = Number.isInteger(s.index) ? s.index : -1;
$queue.textContent = (total > 0 && idx >= 0)
? `${idx + 1} / ${total}${s.paused ? ' (paused)' : ''}`
: '—';
$elapsed.textContent = `${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}`;
});
obs.onCustom('mpd:cmd', (d) => {
$cmd.textContent = `${d.type || '?'} @ ${new Date().toLocaleTimeString()}`;
});
} catch (e) {
setWs(`connect failed (${e.message}) — retrying`, 'err');
setTimeout(connectOBS, 2500);
}
}
connectOBS();
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(() => {});
}
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);
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);
setInterval(broadcastState, 1000);
// Only command source now: OBS WS `mpd:cmd`. The Twitch bot and the
// Laravel `POST /cmd/{type}` route both broadcast through this same event.
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);
}
}
if (queue.length > 0) {
playCurrent();
} else {
console.warn('[music] no tracks — run `php artisan rig:playlist`');
}
</script>
</body>
</html>

View File

@@ -54,12 +54,17 @@
z-index: 1;
}
.pane.terminal {
/* Override hud.css `.terminal { overflow: hidden }` so the `┤ TERMINAL ├`
pane-tab can stick out 14px above the pane border. The scrolling
clip still happens inside #termOutput. */
overflow: visible;
padding: 28px 36px;
font-size: 24px;
line-height: 1.55;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
}
.pane.np-pane { overflow: visible; }
.np-pane {
padding: 24px 22px 20px;
font-size: 18px;