Compare commits

..

2 Commits

Author SHA1 Message Date
Jakub Zych
0b4f121a92 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.
2026-05-21 13:08:49 +02:00
Jakub Zych
059f069ef4 webapp: introduce Laravel app under webapp/ for scene overlays
Replaces the per-scene HTML directories (landing/, loading/, game/,
desktop/, goodbye/, music-box/, music/) with a single Laravel app
serving every overlay over HTTP. Supervisord runs `php artisan serve`
on 127.0.0.1:1118 and the OBS scene JSON now references HTTP routes
instead of file:// URLs.

Highlights:
 - public/css/hud.css consolidates the duplicated HUD chrome,
   scanlines/vignette/flicker, terminal styling, and pulse keyframes
   that were copy-pasted across all seven scenes.
 - Blade partials own hud-strip, crt-overlays, obs-ws-scripts,
   camera-frame, screen-frame; the seven scenes extend a shared
   overlay layout.
 - Artisan commands (`rig:setup`, `rig:telemetry`, `rig:loading`,
   `rig:playlist`, `rig:cmd`) replace the shell scripts that wrote
   per-rig JSON snapshots. TwitchHelix + HardwareSnapshot services
   handle the work the bash + Python helpers used to.
 - ObsWsClient + MusicCommandController kill the 250 ms cmd.js poll
   in the music daemon: POST /cmd/{skip|prev|pause|resume} opens a
   short-lived Pawl WS, authenticates, and broadcasts mpd:cmd.
 - AudioController streams files from the configured music dirs so
   CEF can load tracks under the HTTP origin (Chromium blocks
   HTTP-origin pages from loading file:// media).
 - DataController serves /data/playlist.js (with ETag mtime cache)
   and /cover.jpg (no-store) so the existing overlays' window.__PLAYLIST
   and cover.jpg cache-bust pattern keeps working.

scripts/obs-webapp.supervisord.conf is the supervisord unit; install
to /etc/supervisor.d/obs-webapp.conf.
scripts/rewrite-scene-urls.py is a one-shot tool that rewrites
basic/scenes/Default_Stream_HUD.json from file:// to HTTP URLs.
basic/scenes/Default_Stream_HUD.json.pre-webapp is the rollback
artifact (made with OBS closed; full pre-migration state).

The seven old scene directories, vendor/, and the bash scripts are
still on disk pending visual verification; the next commit will
prune them.
2026-05-21 12:47:10 +02:00
104 changed files with 19284 additions and 80 deletions

60
.gitignore vendored
View File

@@ -24,36 +24,47 @@ plugin_config/obs-websocket/
# Machine-local generated data # Machine-local generated data
# ============================================================ # ============================================================
# Generated wrapper for telemetry.json — built by telemetry.sh, not edited. # Per-session rig data written by `php artisan rig:loading`, `rig:playlist`,
# (The JSON itself is tracked: it's the hand-edited source for the wrapper.) # `rig:telemetry`. Re-derived on demand from the live system + Twitch API.
landing/telemetry.js webapp/storage/data/*.json
# 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 # OBS WebSocket connection details (port + password) — auto-generated by
# setup.sh from your existing plugin_config/obs-websocket/config.json. # `php artisan rig:setup` from plugin_config/obs-websocket/config.json.
vendor/obs-config.js webapp/public/js/obs-config.js
# Album-art cache written by bridges/mpd-state.py on every track change — # Album-art cache written by bridges/mpd-state.py on every track change —
# overlays load it as `<img src="../bridges/cover.jpg?v=<hash>">`. Pure runtime state. # the music-box/cover overlay loads it through `/cover.jpg?v=<hash>`.
bridges/cover.jpg bridges/cover.jpg
# Local backups created during edits — keep out of git noise. # Audio files for the loading/music-box scenes (any format). Kept out of git
landing/*.manual # regardless of how they got there (yt-dlp, manual copy, etc.).
loading/*.manual /playlist/
# Local backups created during rig:telemetry review.
webapp/storage/data/*.manual
# Laravel-native ignores (composer deps, log + cache + session dirs).
/webapp/vendor/
/webapp/node_modules/
/webapp/storage/logs/*.log
/webapp/storage/framework/cache/data/
/webapp/storage/framework/sessions/
/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 # OBS runtime / generated state — no value in version control
@@ -99,3 +110,4 @@ __pycache__/
*.pyc *.pyc
*.pyo *.pyo
temp temp
.claude/

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) ## 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 ## 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. - `plugin_manager/modules.json` — third-party plugins OBS loads.
- `logs/`, `profiler_data/`, `.sentinel/` — runtime state. - `logs/`, `profiler_data/`, `.sentinel/` — runtime state.
**Custom overlays (browser sources rendering local HTML — safe to edit while OBS is up):** **Scene overlays (`webapp/` — Laravel app served on 127.0.0.1:1118 by supervisord):**
- `landing/` — Fallout-style "PLEASE STAND BY" overlay + telemetry + 12 s static-hum loop. - `webapp/resources/views/overlays/landing.blade.php` — Fallout-style "PLEASE STAND BY" overlay + telemetry rotator.
- `loading/` — terminal-style Project Loading overlay + per-session manifest + flat playlist index + legacy `cmd.js`. - `webapp/resources/views/overlays/loading.blade.php` — terminal-style Project Loading overlay + countdown.
- `game/index.html` — Game scene HUD (TRANSMISSION ID, signal, clock, camera frame, status bar). Live-syncs camera transform via OBS WS. - `webapp/resources/views/overlays/game.blade.php` — Game HUD (camera-frame OBS-WS sync, status bar).
- `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). - `webapp/resources/views/overlays/desktop.blade.php` — Desktop HUD (camera + screen-capture frames).
- `goodbye/index.html` — sign-off overlay. - `webapp/resources/views/overlays/goodbye.blade.php` — sign-off terminal + "OFFLINE FOR" counter.
- `music-box/index.html` — full-screen Music Box terminal (scrolling `[audio]` log + pinned 3-up next + chat help strip). - `webapp/resources/views/overlays/music/box.blade.php` — full-screen Music Box terminal.
- `music-box/widget.html` — compact 549×880 sidebar widget for the Desktop scene. **Authored at native pixel size** (`bounds_type: 0`, no stretch). - `webapp/resources/views/overlays/music/widget.blade.php` — compact 549×880 widget (`bounds_type: 0`, no stretch).
- `music/index.html` — legacy "Music Daemon" browser source. - `webapp/resources/views/overlays/music/cover.blade.php` — album-art widget (`?bars=0` for compact mode).
- `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client. `vendor/obs-config.js` is the auto-generated WS connection config (gitignored). - `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:** **Audio + automation surfaces:**
- `bridges/mpd-state.py` — MPD → OBS WS state bridge (`obs-mpd-bridge.service`). - `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` + optional Mattermost going-live notifier (`obs-twitch-bot.service`). - `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 used by `scripts/loading.sh`. - `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). - `twitch-bot/.env*` — secrets (per-rig, never copy).
**Scripts (`.sh` lives only here):** **Scripts (`.sh` lives only here):**
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig. - `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig (now also installs the supervisord program + runs `composer install`).
- `scripts/setup.sh` — generate `vendor/obs-config.js` after a WS password change. - `scripts/obs-webapp.supervisord.conf` — supervisord program file; install to `/etc/supervisor.d/obs-webapp.conf`.
- `scripts/telemetry.sh` — refresh `landing/telemetry.json` + wrap to `.js`. - `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/loading.sh` — interactive `loading.json` builder; pushes title/category to Twitch.
- `scripts/playlist.sh` — playlist sync + legacy daemon control.
- `scripts/convert.sh`, `scripts/clean.sh` — yt-dlp `.webm` → AAC `.m4a` re-encode + audit. - `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 ## 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. 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: **Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use:
```bash ```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

@@ -269,7 +269,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"url": "file:///home/jin/.config/obs-studio/landing/index.html", "is_local_file": false,
"url": "http://127.0.0.1:1118/landing",
"width": 2560, "width": 2560,
"height": 1336, "height": 1336,
"fps": 30, "fps": 30,
@@ -309,7 +310,7 @@
"id": "ffmpeg_source", "id": "ffmpeg_source",
"versioned_id": "ffmpeg_source", "versioned_id": "ffmpeg_source",
"settings": { "settings": {
"local_file": "/home/jin/.config/obs-studio/landing/static-hum.wav", "local_file": "/home/jin/.config/obs-studio/webapp/public/audio/static-hum.wav",
"close_when_inactive": false, "close_when_inactive": false,
"hw_decode": false, "hw_decode": false,
"is_local_file": true, "is_local_file": true,
@@ -350,9 +351,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/loading/index.html", "url": "http://127.0.0.1:1118/loading",
"url": "file:///home/jin/.config/obs-studio/loading/index.html",
"width": 2560, "width": 2560,
"height": 1336, "height": 1336,
"restart_when_active": true, "restart_when_active": true,
@@ -419,8 +419,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/music/index.html", "url": "http://127.0.0.1:1118/music-daemon",
"width": 400, "width": 400,
"height": 100, "height": 100,
"shutdown": true, "shutdown": true,
@@ -456,8 +456,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/game/index.html", "url": "http://127.0.0.1:1118/game",
"width": 2560, "width": 2560,
"height": 1336, "height": 1336,
"fps": 15, "fps": 15,
@@ -494,8 +494,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/goodbye/index.html", "url": "http://127.0.0.1:1118/goodbye",
"width": 2560, "width": 2560,
"height": 1336, "height": 1336,
"fps_custom": true, "fps_custom": true,
@@ -536,7 +536,7 @@
"mixers": 255, "mixers": 255,
"sync": 0, "sync": 0,
"flags": 0, "flags": 0,
"volume": 0.20938195288181305, "volume": 0.19247779250144958,
"balance": 0.5, "balance": 0.5,
"enabled": true, "enabled": true,
"muted": false, "muted": false,
@@ -567,7 +567,7 @@
"mixers": 255, "mixers": 255,
"sync": 0, "sync": 0,
"flags": 0, "flags": 0,
"volume": 0.0053968820720911026, "volume": 0.01389647088944912,
"balance": 0.5, "balance": 0.5,
"enabled": true, "enabled": true,
"muted": false, "muted": false,
@@ -593,8 +593,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/desktop/index.html", "url": "http://127.0.0.1:1118/desktop",
"width": 2560, "width": 2560,
"height": 1336, "height": 1336,
"fps": 15, "fps": 15,
@@ -631,8 +631,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/music-box/index.html", "url": "http://127.0.0.1:1118/music-box",
"width": 2560, "width": 2560,
"height": 1336, "height": 1336,
"fps": 30, "fps": 30,
@@ -670,9 +670,9 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"is_local_file": true, "is_local_file": false,
"local_file": "/home/jin/.config/obs-studio/music-box/widget.html",
"undo_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18", "undo_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18",
"url": "http://127.0.0.1:1118/music-box/widget",
"width": 549, "width": 549,
"height": 880, "height": 880,
"fps": 15, "fps": 15,
@@ -709,7 +709,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"url": "file:///home/jin/.config/obs-studio/music-box/cover.html" "is_local_file": false,
"url": "http://127.0.0.1:1118/music-box/cover"
}, },
"mixers": 255, "mixers": 255,
"sync": 0, "sync": 0,
@@ -741,7 +742,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"url": "file:///home/jin/.config/obs-studio/music-box/cover.html?bars=0" "is_local_file": false,
"url": "http://127.0.0.1:1118/music-box/cover?bars=0"
}, },
"mixers": 255, "mixers": 255,
"sync": 0, "sync": 0,
@@ -773,7 +775,8 @@
"id": "browser_source", "id": "browser_source",
"versioned_id": "browser_source", "versioned_id": "browser_source",
"settings": { "settings": {
"url": "file:///home/jin/.config/obs-studio/music-box/nc-music-box.html", "is_local_file": false,
"url": "http://127.0.0.1:1118/music-box/nc",
"width": 2560, "width": 2560,
"height": 1331, "height": 1331,
"fps": 15, "fps": 15,
@@ -3683,8 +3686,8 @@
"name": "Good Bye" "name": "Good Bye"
} }
], ],
"current_scene": "Good Bye", "current_scene": "Music Box",
"current_program_scene": "Good Bye", "current_program_scene": "Music Box",
"canvases": [], "canvases": [],
"current_transition": "Luma Wipe", "current_transition": "Luma Wipe",
"transition_duration": 1000, "transition_duration": 1000,
@@ -3716,7 +3719,7 @@
"saved_projectors": [], "saved_projectors": [],
"preview_locked": false, "preview_locked": false,
"scaling_enabled": false, "scaling_enabled": false,
"scaling_level": -12, "scaling_level": -5,
"scaling_off_x": 0.0, "scaling_off_x": 0.0,
"scaling_off_y": 0.0, "scaling_off_y": 0.0,
"modules": { "modules": {

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@ RIG_NAME_TC="${RIG_NAME^}" # title-case: ignia → Ignia
# ── output helpers ───────────────────────────────────────────────────────── # ── output helpers ─────────────────────────────────────────────────────────
PHASE=0 PHASE=0
TOTAL=9 TOTAL=10
step() { PHASE=$((PHASE+1)); printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$PHASE" "$TOTAL" "$*"; } 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' "$*"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
skip() { printf ' \033[90m·\033[0m %s\n' "$*"; } skip() { printf ' \033[90m·\033[0m %s\n' "$*"; }
@@ -95,6 +95,8 @@ PACMAN_PKGS=(
mpd mpc mpd mpc
avahi nss-mdns avahi nss-mdns
fontconfig fontconfig
php composer
supervisor
) )
if command -v pacman >/dev/null; then if command -v pacman >/dev/null; then
@@ -558,17 +560,87 @@ PY
fi fi
fi fi
if [[ -f "$OBS_DIR/vendor/obs-config.js" ]]; then if [[ -f "$OBS_DIR/webapp/public/js/obs-config.js" ]]; then
ok "vendor/obs-config.js present" ok "webapp/public/js/obs-config.js present"
else else
if would "bash scripts/setup.sh"; then if would "php artisan rig:setup"; then
bash "$DIR/setup.sh" || warn "setup.sh failed — re-run after OBS WS port is reachable" ( cd "$OBS_DIR/webapp" && php artisan rig:setup ) \
|| warn "rig:setup failed — re-run after OBS WS port is reachable"
fi fi
fi 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" 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. # Refresh telemetry so the rig name is correct on this machine.
if (( ! CHECK_ONLY )); then if (( ! CHECK_ONLY )); then
if would "scripts/telemetry.sh --collect --no-review (rig snapshot)"; then if would "php artisan rig:telemetry --collect (rig snapshot)"; then
bash "$DIR/telemetry.sh" --collect --no-review || \ ( cd "$OBS_DIR/webapp" && php artisan rig:telemetry --collect ) || \
warn "telemetry.sh --collect failed — re-run interactively to review" warn "rig:telemetry failed — re-run interactively to review"
ok "telemetry refreshed (rig=$RIG_NAME_TC)" ok "telemetry refreshed (rig=$RIG_NAME_TC)"
fi fi
fi fi
@@ -620,7 +692,7 @@ cat <<DONE
systemctl --user restart obs-twitch-bot.service systemctl --user restart obs-twitch-bot.service
5. Review telemetry interactively: 5. Review telemetry interactively:
bash scripts/telemetry.sh --collect cd webapp && php artisan rig:telemetry --collect
6. Launch OBS: 6. Launch OBS:
flatpak run com.obsproject.Studio flatpak run com.obsproject.Studio
@@ -633,6 +705,8 @@ cat <<DONE
Sanity checks: Sanity checks:
pactl list short sinks | grep -E 'mpd_stream|discord_stream' pactl list short sinks | grep -E 'mpd_stream|discord_stream'
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot 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 mpc status
ss -tlnp | grep 4455 ss -tlnp | grep 4455

View File

@@ -0,0 +1,31 @@
; supervisord program file for the OPHI-118 scene overlay webapp.
;
; Listens on 127.0.0.1:1118 (CH 118.0 in the overlay lore, easy to remember).
;
; Install:
; sudo install -m 644 scripts/obs-webapp.supervisord.conf /etc/supervisor.d/obs-webapp.conf
; sudo supervisorctl reread && sudo supervisorctl update
;
; Restart after config / route / env changes:
; sudo supervisorctl restart obs-webapp
;
; Logs:
; tail -F webapp/storage/logs/supervisord.{out,err}.log
[program:obs-webapp]
command=/usr/bin/php artisan serve --host=127.0.0.1 --port=1118
directory=/home/jin/.config/obs-studio/webapp
user=jin
environment=HOME="/home/jin",USER="jin",PATH="/usr/local/sbin:/usr/local/bin:/usr/bin"
autostart=true
autorestart=true
startsecs=2
startretries=3
stopwaitsecs=10
stopsignal=TERM
stdout_logfile=/home/jin/.config/obs-studio/webapp/storage/logs/supervisord.out.log
stderr_logfile=/home/jin/.config/obs-studio/webapp/storage/logs/supervisord.err.log
stdout_logfile_maxbytes=10MB
stdout_logfile_backups=3
stderr_logfile_maxbytes=10MB
stderr_logfile_backups=3

82
scripts/rewrite-scene-urls.py Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Rewrite OBS browser_source URLs in a scene-collection JSON from `file://` to
`http://127.0.0.1:1118/`.
Usage:
python3 scripts/rewrite-scene-urls.py basic/scenes/Default_Stream_HUD.json
Idempotent: runs against a partially-converted file are no-ops.
Also moves the landing/static-hum.wav ffmpeg_source path to its new home under
webapp/public/audio/.
OBS MUST be closed when this runs — OBS rewrites scene files on exit.
"""
import json
import re
import sys
BROWSER_MAPPING = {
'landing/index.html': 'http://127.0.0.1:1118/landing',
'loading/index.html': 'http://127.0.0.1:1118/loading',
'game/index.html': 'http://127.0.0.1:1118/game',
'desktop/index.html': 'http://127.0.0.1:1118/desktop',
'goodbye/index.html': 'http://127.0.0.1:1118/goodbye',
'music/index.html': 'http://127.0.0.1:1118/music-daemon',
'music-box/index.html': 'http://127.0.0.1:1118/music-box',
'music-box/widget.html': 'http://127.0.0.1:1118/music-box/widget',
'music-box/cover.html': 'http://127.0.0.1:1118/music-box/cover',
'music-box/nc-music-box.html': 'http://127.0.0.1:1118/music-box/nc',
}
AUDIO_OLD = 'landing/static-hum.wav'
AUDIO_NEW = 'webapp/public/audio/static-hum.wav'
def rewrite_browser_source(src):
s = src.get('settings', {})
for old, new in BROWSER_MAPPING.items():
for k in ('url', 'local_file'):
v = s.get(k, '')
if old in v:
# Preserve query strings (e.g. ?bars=0).
m = re.search(r'\?[^"\s]*$', v)
qs = m.group(0) if m else ''
s['url'] = new + qs
s.pop('local_file', None)
s['is_local_file'] = False
return True
return False
def rewrite_audio_source(src):
s = src.get('settings', {})
lf = s.get('local_file', '')
if lf.endswith(AUDIO_OLD):
s['local_file'] = lf.replace(AUDIO_OLD, AUDIO_NEW)
return True
return False
def main():
if len(sys.argv) != 2:
print(__doc__, file=sys.stderr)
return 2
path = sys.argv[1]
doc = json.load(open(path))
rewrites = 0
for src in doc.get('sources', []):
sid = src.get('id', '')
if sid == 'browser_source':
if rewrite_browser_source(src):
rewrites += 1
elif sid == 'ffmpeg_source':
if rewrite_audio_source(src):
rewrites += 1
json.dump(doc, open(path, 'w'), indent=4, ensure_ascii=False)
print(f'wrote {path} ({rewrites} sources rewritten)')
if __name__ == '__main__':
sys.exit(main() or 0)

18
webapp/.editorconfig Normal file
View File

@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4

11
webapp/.gitattributes vendored Normal file
View File

@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore

27
webapp/.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db

58
webapp/README.md Normal file
View File

@@ -0,0 +1,58 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications.
## Learning Laravel
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
## Agentic Development
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
```bash
composer require laravel/boost --dev
php artisan boost:install
```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\ObsWsClient;
use Illuminate\Console\Command;
use Throwable;
class RigCmdCommand extends Command
{
protected $signature = 'rig:cmd {type : skip|prev|pause|resume}';
protected $description = 'Broadcast a music daemon command over OBS WS (CLI mirror of POST /cmd/{type})';
public function handle(ObsWsClient $ws): int
{
$type = strtolower((string) $this->argument('type'));
if (!in_array($type, ['skip', 'prev', 'pause', 'resume'], true)) {
$this->error('type must be one of: skip, prev, pause, resume');
return self::FAILURE;
}
try {
$ws->broadcast('mpd:cmd', ['type' => $type]);
} catch (Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->info("{$type}");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Services\TwitchHelix;
use App\Support\RigData;
use Illuminate\Console\Command;
use Throwable;
class RigLoadingCommand extends Command
{
protected $signature = 'rig:loading {--no-twitch : Skip pushing the channel update to Twitch}';
protected $description = 'Interactive Project Loading manifest builder';
public function handle(RigData $rig, TwitchHelix $twitch): int
{
$prev = $rig->loading();
$this->line('── Project Loading manifest ──');
$this->line(' (press Enter to keep [defaults])');
$this->line('');
$game = null;
$gameId = null;
$defaultGame = (string) ($prev['game'] ?? '');
while (true) {
$query = (string) $this->ask('Game (search)', $defaultGame ?: null);
if ($query === '') {
$this->error(' ✗ Game is required');
continue;
}
if ($twitch->enabled()) {
$cachedId = (string) ($prev['gameId'] ?? '');
if ($query === $defaultGame && preg_match('/^\d+$/', $cachedId)) {
$game = $defaultGame;
$gameId = $cachedId;
$this->line(" ↻ reusing cached: {$game} (id={$gameId})");
break;
}
try {
$hit = $twitch->searchCategory($query);
} catch (Throwable $e) {
$this->warn(' ! Twitch search failed: ' . $e->getMessage());
if ($this->confirm('Use the raw input without resolving an id?', true)) {
$game = $query;
break;
}
continue;
}
if ($hit === null) {
$this->warn(' ! no Twitch matches for "' . $query . '"');
continue;
}
$game = $hit['name'];
$gameId = $hit['id'];
$this->line(" ↳ matched: {$game} (id={$gameId})");
break;
}
$game = $query;
break;
}
$subtitle = (string) $this->ask('Subtitle (mode / episode / note)', $prev['subtitle'] ?? null);
$countdown = (int) $this->ask('Countdown (minutes)', (string) ($prev['countdownMin'] ?? 5));
$camera = $this->confirm('Camera', (bool) ($prev['camera'] ?? true));
$microphone = $this->confirm('Microphone', (bool) ($prev['microphone'] ?? true));
$data = [
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
'game' => $game,
'gameId' => $gameId,
'subtitle' => $subtitle ?: null,
'countdownMin' => $countdown,
'camera' => $camera,
'microphone' => $microphone,
];
$path = rtrim(config('rig.storage_data'), '/') . '/loading.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
$this->info('');
$this->info(" ✓ wrote {$path}");
// Push to Twitch — soft-fail so a network blip doesn't block the local manifest.
if (!$this->option('no-twitch') && $twitch->enabled() && $gameId) {
$title = $subtitle ?: $game;
try {
$twitch->setChannel($title, $gameId);
$this->info(" ✓ Twitch channel updated → \"{$title}\" / category {$game}");
} catch (Throwable $e) {
$this->warn(' ! Twitch sync failed (manifest still saved): ' . $e->getMessage());
}
}
$this->line('');
$this->line(' → refresh the Project Loading browser source in OBS.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Process\Process;
use Throwable;
class RigPlaylistCommand extends Command
{
protected $signature = 'rig:playlist';
protected $description = 'Index audio under the configured music roots → storage/data/playlist.json';
private const EXTS = ['m4a', 'mp3', 'opus', 'ogg', 'flac', 'webm', 'aac', 'wav'];
public function handle(): int
{
$roots = array_values(array_filter((array) config('rig.music_dirs')));
if (empty($roots)) {
$this->warn(' ! no music dirs configured (set MUSIC_DIR_1 in .env)');
}
$ffprobe = (string) config('rig.ffprobe', '/usr/bin/ffprobe');
if (!is_executable($ffprobe)) {
$this->error(" ✗ ffprobe not found at {$ffprobe} (install ffmpeg or set FFPROBE_BIN)");
return self::FAILURE;
}
$this->line('── Indexing audio roots ──');
foreach ($roots as $r) {
$this->line(is_dir($r) ? " + {$r}" : " ! {$r} (missing — skipped)");
}
$tracks = [];
foreach ($roots as $root) {
if (!is_dir($root)) continue;
$finder = (new Finder())
->files()
->in($root)
->name('/\.(' . implode('|', self::EXTS) . ')$/i')
->ignoreUnreadableDirs()
->sortByName();
foreach ($finder as $file) {
$name = $file->getFilename();
$base = $file->getFilenameWithoutExtension();
// Skip yt-dlp intermediates (e.g. foo.f140.m4a, foo.temp.opus).
if (preg_match('/\.(?:f\d+|temp|part)$/i', $base)) continue;
$path = $file->getRealPath();
$meta = $this->probe($ffprobe, $path);
$tracks[] = [
'title' => $this->cleanTitle($base),
'file' => 'file://' . $this->urlEncodePath($path),
'durationSec' => isset($meta['durationSec']) ? (int) $meta['durationSec'] : null,
];
}
}
$doc = [
'syncedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'sourceDirs' => $roots,
'trackCount' => count($tracks),
'tracks' => $tracks,
];
$path = rtrim(config('rig.storage_data'), '/') . '/playlist.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
$this->line('');
$this->info(" ✓ wrote {$path} ({$doc['trackCount']} tracks)");
$this->line(' → refresh the Music Daemon browser source in OBS to reload the queue.');
return self::SUCCESS;
}
private function probe(string $ffprobe, string $path): array
{
try {
$proc = new Process([
$ffprobe, '-v', 'error',
'-show_entries', 'format=duration:format_tags=title,artist',
'-of', 'json',
$path,
]);
$proc->setTimeout(10);
$proc->run();
if (!$proc->isSuccessful()) return [];
$json = json_decode($proc->getOutput(), true) ?: [];
$fmt = $json['format'] ?? [];
return [
'durationSec' => isset($fmt['duration']) ? (float) $fmt['duration'] : null,
];
} catch (Throwable) {
return [];
}
}
/**
* Mirrors scripts/playlist.sh's clean_title():
* - drop "| ..." (or fullwidth ) tails (genre/label noise)
* - strip trailing YouTube IDs in brackets like "[-XxZTgMWKV0]"
* - strip "[NCS Release]" / "(NCS10 Release)" suffixes
*/
private function cleanTitle(string $t): string
{
$t = preg_replace('/\s*[|].*$/u', '', $t) ?? $t;
$t = preg_replace('/\s*\[[A-Za-z0-9_-]{11}\]\s*$/', '', $t) ?? $t;
$t = preg_replace(
'/\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$/i',
'',
$t
) ?? $t;
return trim($t);
}
private function urlEncodePath(string $path): string
{
// Same as Python's urllib.parse.quote(..., safe='/') — keep separators, encode the rest.
return implode('/', array_map('rawurlencode', explode('/', $path)));
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class RigSetupCommand extends Command
{
protected $signature = 'rig:setup';
protected $description = 'Generate public/js/obs-config.js and OBS_WS_* env vars from plugin_config/obs-websocket/config.json';
public function handle(): int
{
$src = (string) config('rig.obs_ws_config_json');
if (!is_file($src)) {
$this->error("{$src} not found.");
$this->line(' Open OBS once with obs-websocket loaded so it generates the config.');
return self::FAILURE;
}
$cfg = json_decode((string) file_get_contents($src), true);
if (!is_array($cfg)) {
$this->error("{$src} did not parse as JSON.");
return self::FAILURE;
}
$port = (int) ($cfg['server_port'] ?? 4455);
$pass = (string) ($cfg['server_password'] ?? '');
$url = "ws://localhost:{$port}";
$out = (string) config('rig.obs_config_js');
@mkdir(dirname($out), 0775, true);
$js = <<<JS
// Auto-generated by `php artisan rig:setup`. Reflects the current contents of
// plugin_config/obs-websocket/config.json. Re-run if you rotate the password.
window.__OBSWS = {
url: {$this->jsString($url)},
password: {$this->jsString($pass)},
};
JS;
file_put_contents($out, $js);
$this->info(" ✓ wrote {$out}");
// Mirror into .env so MusicCommandController / ObsWsClient can authenticate.
$this->writeEnv('OBS_WS_URL', $url);
$this->writeEnv('OBS_WS_PASSWORD', $pass);
$this->info(' ✓ updated .env OBS_WS_URL / OBS_WS_PASSWORD');
$this->line('');
$this->line(" url = {$url}");
$this->line(' password = ' . ($pass === '' ? '(none)' : '(set, ' . strlen($pass) . ' chars)'));
$this->line('');
$this->line(' → if obs-webapp is running under supervisord, restart it so the new env takes effect:');
$this->line(' sudo supervisorctl restart obs-webapp');
return self::SUCCESS;
}
private function jsString(string $s): string
{
return json_encode($s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
private function writeEnv(string $key, string $value): void
{
$env = base_path('.env');
$raw = is_file($env) ? file_get_contents($env) : '';
$line = $key . '=' . $this->escapeEnvValue($value);
if (preg_match("/^{$key}=.*$/m", $raw)) {
$raw = preg_replace("/^{$key}=.*$/m", $line, $raw);
} else {
$raw = rtrim($raw, "\n") . "\n" . $line . "\n";
}
file_put_contents($env, $raw);
}
private function escapeEnvValue(string $v): string
{
if ($v === '') return '';
if (preg_match('/[\s"#=]/', $v)) {
return '"' . str_replace('"', '\"', $v) . '"';
}
return $v;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Services\HardwareSnapshot;
use App\Support\RigData;
use Illuminate\Console\Command;
class RigTelemetryCommand extends Command
{
protected $signature = 'rig:telemetry {--collect : Re-read hardware + OBS profile (default: only refresh if file is missing)}';
protected $description = 'Capture rig hardware + OBS profile snapshot → storage/data/telemetry.json';
public function handle(HardwareSnapshot $snap, RigData $rig): int
{
$path = rtrim(config('rig.storage_data'), '/') . '/telemetry.json';
if ($this->option('collect') || !is_file($path)) {
$data = $snap->collect();
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
$this->info(" ✓ wrote {$path}");
} else {
$this->line(" · {$path} already exists — use --collect to re-read hardware.");
}
$current = $rig->telemetry();
$this->line('');
$this->line(" rig = " . ($current['rig'] ?? '?'));
$this->line(" cpu = " . ($current['cpu']['model'] ?? '?'));
if (!empty($current['gpu']['name'])) {
$this->line(" gpu = " . $current['gpu']['name']);
}
if (!empty($current['obs']['output']['w'])) {
$this->line(" output = {$current['obs']['output']['w']}×{$current['obs']['output']['h']} @ {$current['obs']['fps']} fps");
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
/**
* Streams audio files from the configured music_dirs via HTTP so the
* music-daemon page (served on http://127.0.0.1:8000/) can load them.
*
* Chromium blocks HTTP-origin pages from loading `file://` media as a
* mixed-protocol security policy, which is why the old file://-only
* setup worked but the new HTTP setup needs this.
*
* Path-traversal guard: the resolved file's real path must sit inside
* one of the configured roots; the request 404s otherwise.
*/
class AudioController extends Controller
{
public function stream(Request $request): Response
{
$b64 = (string) $request->query('p', '');
if ($b64 === '') abort(400, 'missing p');
// URL-safe base64; pad back to a multiple of 4 for strict decoding.
$padded = $b64 . str_repeat('=', (4 - strlen($b64) % 4) % 4);
$path = base64_decode(strtr($padded, '-_', '+/'), true);
if ($path === false || $path === '') abort(400, 'bad encoding');
$real = realpath($path);
if ($real === false || !is_file($real)) abort(404, 'file not found: ' . $path);
$allowed = false;
foreach ((array) config('rig.music_dirs') as $root) {
$rootReal = realpath($root);
if ($rootReal && str_starts_with($real . '/', rtrim($rootReal, '/') . '/')) {
$allowed = true;
break;
}
}
if (!$allowed) abort(403, 'outside allowed roots');
$ext = strtolower(pathinfo($real, PATHINFO_EXTENSION));
$mime = match ($ext) {
'm4a', 'aac' => 'audio/mp4',
'mp3' => 'audio/mpeg',
'opus', 'ogg'=> 'audio/ogg',
'flac' => 'audio/flac',
'webm' => 'audio/webm',
'wav' => 'audio/wav',
default => 'application/octet-stream',
};
// BinaryFileResponse handles Range requests automatically, which the
// HTML5 <audio> element issues when seeking.
$resp = new BinaryFileResponse($real, 200, [
'Content-Type' => $mime,
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'public, max-age=86400',
]);
return $resp;
}
/** Encode an absolute path into the URL-safe base64 used by the {@see stream} route. */
public static function urlFor(string $absolutePath): string
{
return '/track?p=' . rtrim(strtr(base64_encode($absolutePath), '+/', '-_'), '=');
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Support\RigData;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class DataController extends Controller
{
/**
* Serve the playlist as a script-tag-friendly JS global. Cached via ETag on file mtime
* so CEF can revalidate cheaply (the playlist file changes rarely but the body is ~142 KB).
*/
public function playlist(Request $request, RigData $rig)
{
$path = $rig->playlistPath();
$tracks = $rig->playlist();
$mtime = is_file($path) ? filemtime($path) : 0;
$etag = '"' . md5('playlist:' . $mtime) . '"';
if ($request->headers->get('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag);
}
// Rewrite `file://` URIs into `/audio?p=...` HTTP URLs so the
// music-daemon page (served over HTTP) can load tracks. Chromium
// blocks HTTP-origin pages from loading file:// media.
if (isset($tracks['tracks']) && is_array($tracks['tracks'])) {
foreach ($tracks['tracks'] as $i => $track) {
if (!isset($track['file'])) continue;
if (str_starts_with($track['file'], 'file://')) {
$absolute = rawurldecode(substr($track['file'], 7));
$tracks['tracks'][$i]['file'] = \App\Http\Controllers\AudioController::urlFor($absolute);
}
}
}
$body = 'window.__PLAYLIST = ' . json_encode(
$tracks,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
) . ';';
return response($body, 200, [
'Content-Type' => 'application/javascript; charset=utf-8',
'Cache-Control' => 'public, must-revalidate',
'ETag' => $etag,
]);
}
/**
* Serve bridges/cover.jpg. `no-store` so CEF never holds a stale image
* even if a caller forgets the ?v=<hash> cache-bust. 404 returns a 1×1
* transparent PNG so the music-box cover placeholder doesn't get a
* broken-image icon while the bridge is starting up.
*/
public function coverImage(RigData $rig): SymfonyResponse
{
$path = $rig->coverPath();
if (is_file($path) && is_readable($path)) {
return response()->file($path, [
'Content-Type' => 'image/jpeg',
'Cache-Control' => 'no-store, max-age=0',
]);
}
// 1x1 transparent PNG (67 bytes).
$png = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
);
return response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'no-store, max-age=0',
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Services\ObsWsClient;
use Illuminate\Http\Response;
use Throwable;
class MusicCommandController extends Controller
{
public function send(string $type, ObsWsClient $ws): Response
{
try {
$ws->broadcast('mpd:cmd', ['type' => $type]);
} catch (Throwable $e) {
return response($e->getMessage(), 502);
}
return response('', 204);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
class DesktopController extends Controller
{
public function show()
{
return view('overlays.desktop');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
use App\Support\RigData;
class GameController extends Controller
{
public function show(RigData $rig)
{
return view('overlays.game', [
'manifest' => $rig->loading(),
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
use App\Support\RigData;
class GoodbyeController extends Controller
{
public function show(RigData $rig)
{
return view('overlays.goodbye', [
'trackCount' => $rig->playlistCount(),
]);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
use App\Support\RigData;
class LandingController extends Controller
{
public function show(RigData $rig)
{
return view('overlays.landing', [
'telemetry' => $rig->telemetry(),
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
use App\Support\RigData;
class LoadingController extends Controller
{
public function show(RigData $rig)
{
return view('overlays.loading', [
'manifest' => $rig->loading(),
'telemetry' => $rig->telemetry(),
'trackCount' => $rig->playlistCount(),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
use App\Support\RigData;
class MusicBoxController extends Controller
{
public function box(RigData $rig)
{
return view('overlays.music.box', [
'trackCount' => $rig->playlistCount(),
]);
}
public function widget()
{
return view('overlays.music.widget');
}
public function cover()
{
return view('overlays.music.cover');
}
public function nc(RigData $rig)
{
return view('overlays.music.nc', [
'trackCount' => $rig->playlistCount(),
]);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
class MusicDaemonController extends Controller
{
public function show()
{
return view('overlays.music.daemon');
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Providers;
use App\Support\RigData;
use Illuminate\Support\ServiceProvider;
class RigDataServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(RigData::class);
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Services;
/**
* Captures the static device + OBS config snapshot shown on the landing
* overlay's telemetry rotator. Direct port of scripts/telemetry.sh.
*/
class HardwareSnapshot
{
public function collect(): array
{
return [
'collectedAt' => gmdate('Y-m-d\TH:i:s\Z'),
'rig' => $this->rigName(),
'cpu' => $this->cpu(),
'mem' => $this->mem(),
'host' => ['kernel' => $this->kernel()],
'gpu' => $this->gpu(),
'obs' => $this->obs(),
];
}
private function rigName(): string
{
$name = (string) (config('rig.rig_name') ?: gethostname());
return ucfirst(strtolower($name));
}
private function cpu(): array
{
$model = null;
if (is_readable('/proc/cpuinfo')) {
foreach (file('/proc/cpuinfo') as $line) {
if (preg_match('/^model name\s*:\s*(.+)$/', $line, $m)) {
$model = trim($m[1]);
break;
}
}
}
$threads = (int) (shell_exec('nproc 2>/dev/null') ?: 0);
return ['model' => $model, 'threads' => $threads ?: null];
}
private function mem(): array
{
$kb = 0;
if (is_readable('/proc/meminfo')) {
foreach (file('/proc/meminfo') as $line) {
if (preg_match('/^MemTotal:\s+(\d+)\s*kB/', $line, $m)) {
$kb = (int) $m[1];
break;
}
}
}
$gb = $kb > 0 ? round($kb / 1024 / 1024, 1) : null;
return ['totalGB' => $gb];
}
private function kernel(): ?string
{
$k = trim((string) shell_exec('uname -r 2>/dev/null'));
return $k === '' ? null : $k;
}
private function gpu(): array
{
$name = null;
$vram = null;
if (trim((string) shell_exec('command -v nvidia-smi 2>/dev/null')) !== '') {
$row = trim((string) shell_exec(
'nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null | head -n1'
));
if ($row !== '') {
[$n, $v] = array_pad(array_map('trim', explode(',', $row, 2)), 2, null);
$name = $n !== '' ? $n : null;
$vram = is_numeric($v) ? (int) $v : null;
}
}
return ['name' => $name, 'vramTotalMB' => $vram];
}
private function obs(): ?array
{
$obsDir = rtrim((string) config('rig.obs_studio_dir'), '/');
$userIni = $obsDir . '/user.ini';
if (!is_file($userIni)) return null;
$user = $this->parseIni($userIni);
$profile = $user['Basic']['Profile'] ?? 'Untitled';
$renderer = $user['Video']['Renderer'] ?? null;
$profileDir = "{$obsDir}/basic/profiles/{$profile}";
$basicIni = "{$profileDir}/basic.ini";
if (!is_file($basicIni)) return null;
$b = $this->parseIni($basicIni);
$mode = $b['Output']['Mode'] ?? null;
$baseW = isset($b['Video']['BaseCX']) ? (int) $b['Video']['BaseCX'] : null;
$baseH = isset($b['Video']['BaseCY']) ? (int) $b['Video']['BaseCY'] : null;
$outW = isset($b['Video']['OutputCX']) ? (int) $b['Video']['OutputCX'] : null;
$outH = isset($b['Video']['OutputCY']) ? (int) $b['Video']['OutputCY'] : null;
$fps = isset($b['Video']['FPSInt']) ? (int) $b['Video']['FPSInt'] : null;
$encRaw = '';
$bitrate = null;
$rc = null;
$keyint = null;
$h264Profile = null;
if ($mode === 'Advanced') {
$encRaw = $b['AdvOut']['Encoder'] ?? '';
$sej = "{$profileDir}/streamEncoder.json";
if (is_file($sej)) {
$j = json_decode((string) file_get_contents($sej), true) ?: [];
$rc = $j['rate_control'] ?? null;
$bitrate = isset($j['bitrate']) ? (int) $j['bitrate'] : null;
$keyint = isset($j['keyint_sec']) ? (int) $j['keyint_sec'] : null;
$h264Profile = $j['profile'] ?? null;
}
} else {
$encRaw = $b['SimpleOutput']['StreamEncoder'] ?? '';
$bitrate = isset($b['SimpleOutput']['VBitrate']) ? (int) $b['SimpleOutput']['VBitrate'] : null;
}
return [
'profile' => $profile,
'renderer' => $renderer,
'outputMode' => $mode,
'canvas' => ['w' => $baseW, 'h' => $baseH],
'output' => ['w' => $outW, 'h' => $outH],
'fps' => $fps,
'color' => [
'format' => $b['Video']['ColorFormat'] ?? null,
'space' => $b['Video']['ColorSpace'] ?? null,
'range' => $b['Video']['ColorRange'] ?? null,
],
'stream' => [
'encoder' => $this->prettifyEncoder($encRaw),
'rateControl' => $rc,
'bitrateKbps' => $bitrate,
'keyintSec' => $keyint,
'profile' => $h264Profile,
],
'audio' => [
'sampleRateHz' => isset($b['Audio']['SampleRate']) ? (int) $b['Audio']['SampleRate'] : null,
'channels' => $b['Audio']['ChannelSetup'] ?? null,
],
];
}
private function prettifyEncoder(string $id): ?string
{
$map = [
'obs_x264' => 'x264', 'x264' => 'x264',
'jim_nvenc' => 'NVENC H.264', 'ffmpeg_nvenc' => 'NVENC H.264',
'obs_nvenc_h264_tex' => 'NVENC H.264', 'obs_nvenc_h264_soft' => 'NVENC H.264',
'obs_nvenc_hevc_tex' => 'NVENC HEVC', 'obs_nvenc_hevc_soft' => 'NVENC HEVC',
'obs_nvenc_av1_tex' => 'NVENC AV1', 'obs_nvenc_av1_soft' => 'NVENC AV1',
'obs_qsv11' => 'QSV',
];
if ($id === '') return null;
return $map[$id] ?? $id;
}
/** @return array<string, array<string, string>> */
private function parseIni(string $path): array
{
$data = [];
$section = null;
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$trim = trim($line);
if ($trim === '' || str_starts_with($trim, ';') || str_starts_with($trim, '#')) continue;
if (preg_match('/^\[(.+)\]$/', $trim, $m)) {
$section = $m[1];
$data[$section] = $data[$section] ?? [];
continue;
}
if ($section !== null && str_contains($line, '=')) {
[$k, $v] = explode('=', $line, 2);
$data[$section][trim($k)] = trim($v);
}
}
return $data;
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace App\Services;
use Exception;
use Ratchet\Client\Connector as PawlConnector;
use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\MessageInterface;
use React\EventLoop\Loop;
use Throwable;
/**
* Short-lived OBS WebSocket v5 client used to broadcast `mpd:cmd` from the
* Laravel side (POST /cmd/{type} and `php artisan rig:cmd`).
*
* Mirrors the auth + protocol opcodes implemented in public/js/obs-ws-mini.js:
* - op 0 Hello server sends auth challenge if password set
* - op 1 Identify client sends auth string + event subscriptions
* - op 2 Identified identification OK; we can now send Requests
* - op 6 Request BroadcastCustomEvent with eventData = { _type, ...payload }
* - op 7 Response request acknowledged
*
* Auth string: base64(SHA256(base64(SHA256(password + salt)) + challenge))
*
* Connect identify broadcast close, all inside a single React event-loop
* tick. Total wall time on localhost is typically 3080 ms.
*/
class ObsWsClient
{
private string $url;
private string $password;
private int $timeoutMs;
public function __construct(?string $url = null, ?string $password = null, int $timeoutMs = 5000)
{
$this->url = $url ?? (string) config('rig.obs_ws.url');
$this->password = $password ?? (string) config('rig.obs_ws.password');
$this->timeoutMs = $timeoutMs;
}
/**
* Broadcast a CustomEvent through OBS WS to every connected client.
* `_type` is added to eventData so the mini client's `onCustom(typeFilter)` works.
*/
public function broadcast(string $type, array $payload = []): void
{
$eventData = array_merge(['_type' => $type], $payload);
$this->sendRequest('BroadcastCustomEvent', ['eventData' => $eventData]);
}
public function sendRequest(string $requestType, array $requestData = []): array
{
$loop = Loop::get();
$connector = new PawlConnector($loop);
$result = null;
$error = null;
$identified = false;
$requestId = 'php-' . bin2hex(random_bytes(6));
$promise = $connector($this->url);
$promise->then(function (WebSocket $conn) use (&$result, &$error, &$identified, $requestId, $requestType, $requestData) {
$conn->on('message', function (MessageInterface $msg) use ($conn, &$result, &$error, &$identified, $requestId, $requestType, $requestData) {
$payload = json_decode((string) $msg, true);
if (!is_array($payload) || !isset($payload['op'])) return;
try {
switch ($payload['op']) {
case 0: // Hello
$auth = null;
if (isset($payload['d']['authentication'])) {
if ($this->password === '') {
$error = new Exception('OBS WS server requires password but none configured');
$conn->close();
return;
}
$auth = $this->authString(
$payload['d']['authentication']['salt'],
$payload['d']['authentication']['challenge']
);
}
$identify = [
'op' => 1,
'd' => [
'rpcVersion' => 1,
'authentication' => $auth,
'eventSubscriptions' => 0,
],
];
// null auth would serialize as JSON null; OBS WS expects the key absent when no auth.
if ($auth === null) unset($identify['d']['authentication']);
$conn->send(json_encode($identify));
break;
case 2: // Identified
$identified = true;
$conn->send(json_encode([
'op' => 6,
'd' => [
'requestType' => $requestType,
'requestId' => $requestId,
'requestData' => (object) $requestData,
],
]));
break;
case 7: // RequestResponse
if (($payload['d']['requestId'] ?? null) !== $requestId) return;
$status = $payload['d']['requestStatus'] ?? [];
if (!empty($status['result'])) {
$result = $payload['d']['responseData'] ?? [];
} else {
$error = new Exception(
'OBS WS request failed: ' . ($status['comment'] ?? 'unknown')
);
}
$conn->close();
break;
}
} catch (Throwable $e) {
$error = $e;
$conn->close();
}
});
$conn->on('close', function () {
Loop::stop();
});
$conn->on('error', function ($e) use (&$error) {
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
Loop::stop();
});
}, function ($e) use (&$error) {
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
Loop::stop();
});
// Hard timeout guard — Loop::stop fires no matter what.
$timer = $loop->addTimer($this->timeoutMs / 1000, function () use (&$error) {
$error = $error ?: new Exception('OBS WS request timed out');
Loop::stop();
});
$loop->run();
$loop->cancelTimer($timer);
if ($error) throw $error;
if (!$identified) throw new Exception('OBS WS never reached Identified');
return $result ?? [];
}
private function authString(string $salt, string $challenge): string
{
$secret = base64_encode(hash('sha256', $this->password . $salt, true));
return base64_encode(hash('sha256', $secret . $challenge, true));
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use RuntimeException;
/**
* Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py
* for use from `php artisan rig:loading`.
*
* Requires TWITCH_CLIENT_ID + TWITCH_TOKEN (user OAuth token with the
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env.
*/
class TwitchHelix
{
private Client $http;
private string $clientId;
private string $token;
private string $broadcasterId;
public function __construct()
{
$this->clientId = (string) config('rig.twitch.client_id');
$this->token = (string) config('rig.twitch.token');
$this->broadcasterId = (string) config('rig.twitch.broadcaster_id');
$this->http = new Client([
'base_uri' => 'https://api.twitch.tv/helix/',
'timeout' => 8.0,
]);
}
public function enabled(): bool
{
return $this->clientId !== '' && $this->token !== '';
}
/**
* Returns [['id' => '12345', 'name' => 'Half-Life 2'], ] for an exact-name match,
* or null if Twitch returned no matches.
*/
public function searchCategory(string $query): ?array
{
try {
$res = $this->http->get('search/categories', [
'headers' => $this->headers(),
'query' => ['query' => $query, 'first' => 20],
]);
} catch (GuzzleException $e) {
throw new RuntimeException('Twitch search failed: ' . $e->getMessage(), 0, $e);
}
$body = json_decode((string) $res->getBody(), true);
$hits = $body['data'] ?? [];
if (empty($hits)) return null;
// Prefer exact (case-insensitive) match; otherwise the first hit Twitch returned.
foreach ($hits as $row) {
if (strcasecmp((string) ($row['name'] ?? ''), $query) === 0) {
return ['id' => (string) $row['id'], 'name' => (string) $row['name']];
}
}
return ['id' => (string) $hits[0]['id'], 'name' => (string) $hits[0]['name']];
}
public function setChannel(string $title, ?string $gameId = null): void
{
if ($this->broadcasterId === '') {
throw new RuntimeException('TWITCH_BROADCASTER_ID not set in .env');
}
$body = ['title' => $title];
if ($gameId !== null && $gameId !== '') $body['game_id'] = $gameId;
try {
$this->http->patch('channels', [
'headers' => $this->headers() + ['Content-Type' => 'application/json'],
'query' => ['broadcaster_id' => $this->broadcasterId],
'json' => $body,
]);
} catch (GuzzleException $e) {
throw new RuntimeException('Twitch PATCH /helix/channels failed: ' . $e->getMessage(), 0, $e);
}
}
private function headers(): array
{
return [
'Authorization' => 'Bearer ' . $this->token,
'Client-Id' => $this->clientId,
];
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Support;
class RigData
{
private array $cache = [];
public function loading(): array
{
return $this->read('loading.json', [
'game' => null, 'gameId' => null, 'subtitle' => null,
'countdownMin' => 5, 'camera' => true, 'microphone' => true,
'compiledAt' => null,
]);
}
public function telemetry(): array
{
return $this->read('telemetry.json', [
'rig' => config('rig.rig_name') ?? 'unknown',
'host' => [], 'cpu' => [], 'gpu' => [], 'mem' => [], 'obs' => [],
]);
}
public function playlist(): array
{
return $this->read('playlist.json', [
'syncedAt' => null, 'sourceDirs' => [], 'trackCount' => 0, 'tracks' => [],
]);
}
public function playlistCount(): int
{
$p = $this->playlist();
return (int) ($p['trackCount'] ?? count($p['tracks'] ?? []));
}
public function cmd(): array
{
return $this->read('cmd.json', ['id' => 0, 'type' => 'none', 'ts' => 0]);
}
public function writeCmd(string $type): array
{
$cmd = ['id' => (int) (microtime(true) * 1000), 'type' => $type, 'ts' => time()];
$this->write('cmd.json', $cmd);
$this->cache['cmd.json'] = $cmd;
return $cmd;
}
public function playlistPath(): string
{
return rtrim(config('rig.storage_data'), '/') . '/playlist.json';
}
public function coverPath(): string
{
return (string) config('rig.cover_jpg');
}
private function read(string $file, array $fallback): array
{
if (isset($this->cache[$file])) return $this->cache[$file];
$path = rtrim(config('rig.storage_data'), '/') . '/' . $file;
if (!is_file($path)) return $this->cache[$file] = $fallback;
$raw = @file_get_contents($path);
if ($raw === false) return $this->cache[$file] = $fallback;
$parsed = json_decode($raw, true);
if (!is_array($parsed)) return $this->cache[$file] = $fallback;
return $this->cache[$file] = $parsed + $fallback;
}
private function write(string $file, array $data): void
{
$dir = rtrim(config('rig.storage_data'), '/');
if (!is_dir($dir)) @mkdir($dir, 0775, true);
file_put_contents(
$dir . '/' . $file,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
}
}

18
webapp/artisan Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

19
webapp/bootstrap/app.php Normal file
View File

@@ -0,0 +1,19 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'cmd/*',
]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create();

2
webapp/bootstrap/cache/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -0,0 +1,5 @@
<?php
return [
App\Providers\RigDataServiceProvider::class,
];

49
webapp/composer.json Normal file
View File

@@ -0,0 +1,49 @@
{
"$schema": "https://getcomposer.org/schema.json",
"name": "ophi118/webapp",
"type": "project",
"description": "OBS scene overlay app for the OPHI-118 streaming rig.",
"keywords": ["laravel", "obs", "overlays"],
"license": "MIT",
"require": {
"php": "^8.3",
"guzzlehttp/guzzle": "^7.10",
"laravel/framework": "^13.8",
"ratchet/pawl": "^0.4.3"
},
"require-dev": {
"laravel/pail": "^1.2.5",
"laravel/pao": "^1.0.6",
"laravel/pint": "^1.27",
"nunomaduro/collision": "^8.6"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

8885
webapp/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

126
webapp/config/app.php Normal file
View File

@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'en'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

117
webapp/config/auth.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'web'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];

136
webapp/config/cache.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];

184
webapp/config/database.php Normal file
View File

@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];

View File

@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

132
webapp/config/logging.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

118
webapp/config/mail.php Normal file
View File

@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];

129
webapp/config/queue.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];

58
webapp/config/rig.php Normal file
View File

@@ -0,0 +1,58 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Storage paths
|---------------------------------------------------------------------------
| Where the Artisan rig:* commands write their JSON snapshots, and where
| controllers + DataController read them back.
*/
'storage_data' => storage_path('data'),
'cover_jpg' => env('RIG_COVER_JPG', base_path('../bridges/cover.jpg')),
'obs_config_js' => env('RIG_OBS_CONFIG_JS', public_path('js/obs-config.js')),
/*
|---------------------------------------------------------------------------
| OBS sources
|---------------------------------------------------------------------------
| Used by Artisan rig:setup and ObsWsClient.
*/
'obs_ws_config_json' => env('RIG_OBS_WS_CONFIG_JSON', base_path('../plugin_config/obs-websocket/config.json')),
'obs_ws' => [
'url' => env('OBS_WS_URL', 'ws://localhost:4455'),
'password' => env('OBS_WS_PASSWORD', ''),
],
/*
|---------------------------------------------------------------------------
| Hardware / OBS profile (telemetry)
|---------------------------------------------------------------------------
*/
'rig_name' => env('RIG_NAME'),
'obs_studio_dir' => env('OBS_STUDIO_DIR', base_path('..')),
'obs_profile' => env('OBS_PROFILE', 'ophi118'),
/*
|---------------------------------------------------------------------------
| Music library (playlist indexer)
|---------------------------------------------------------------------------
*/
'music_dirs' => array_values(array_filter([
env('MUSIC_DIR_1', base_path('../playlist')),
env('MUSIC_DIR_2', ($_SERVER['HOME'] ?? getenv('HOME') ?: '/home/jin') . '/HDD/Music/Electronic/NCS Directory'),
])),
'ffprobe' => env('FFPROBE_BIN', '/usr/bin/ffprobe'),
/*
|---------------------------------------------------------------------------
| Twitch API
|---------------------------------------------------------------------------
*/
'twitch' => [
'client_id' => env('TWITCH_CLIENT_ID'),
'token' => env('TWITCH_TOKEN'),
'broadcaster_id' => env('TWITCH_BROADCASTER_ID'),
],
];

View File

@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Mailgun, Postmark, AWS and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];

233
webapp/config/session.php Normal file
View File

@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];

36
webapp/phpunit.xml Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

25
webapp/public/.htaccess Normal file
View File

@@ -0,0 +1,25 @@
<IfModule mod_rewrite.c>
<IfModule mod_negotiation.c>
Options -MultiViews -Indexes
</IfModule>
RewriteEngine On
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
# Handle X-XSRF-Token Header
RewriteCond %{HTTP:x-xsrf-token} .
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
</IfModule>

576
webapp/public/css/hud.css Normal file
View File

@@ -0,0 +1,576 @@
/* ================================================================
* OPHI-118 shared HUD stylesheet
*
* Extracted from the inline <style> blocks of the seven scene
* overlays. Each scene's Blade view adds only its layout-specific
* rules on top.
* ================================================================ */
:root {
--bg: #07080d;
--ink: #e8e8e0;
--ink-dim: rgba(232, 232, 224, 0.62);
--hud: #4fd2ff;
--hud-dim: #2a7fa6;
--hud-edge: rgba(79, 210, 255, 0.85);
--hud-glow: rgba(79, 210, 255, 0.40);
--accent: #e63a2e;
--warn: #ffd000;
--onair: #5fdc62;
--offair: #ff6f5a;
--term-bg: #04120a;
--term-fg: #5fdc62;
--term-fg-bright: #97f99a;
--term-fg-dim: #2c8d2f;
--term-glow: rgba(80, 220, 100, 0.55);
--term-edge: rgba(80, 220, 100, 0.45);
--term-edge-soft: rgba(80, 220, 100, 0.22);
--frame: rgba(79, 210, 255, 0.85);
--frame-glow: rgba(79, 210, 255, 0.30);
--mono: 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', monospace;
--display: 'DejaVu Sans', 'Liberation Sans', 'Helvetica', 'Arial', sans-serif;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%; height: 100%;
color: var(--ink);
font-family: var(--display);
overflow: hidden;
user-select: none;
}
/* Opaque overlay (landing/loading/goodbye/music-box) full-page background.
Pages add their own grid-template-rows via inline page CSS. */
body.overlay-body {
background: var(--bg);
position: relative;
padding: 40px 72px;
}
/* Transparent overlay (game/desktop) — sits on top of capture sources. */
body.camera-hud-body {
background: transparent;
}
/* ───────── HUD top strip ───────── */
.hud {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 22px;
font-weight: 700;
color: var(--hud);
letter-spacing: 0.08em;
}
.hud .group { display: flex; gap: 28px; align-items: center; }
.hud > .group:nth-child(1) { justify-self: start; }
.hud > .group:nth-child(2) { justify-self: center; }
.hud > .group:nth-child(3) { justify-self: end; }
.hud .dim { color: var(--hud-dim); }
.hud .group > span { display: inline-flex; align-items: center; gap: 12px; }
.hud .led {
display: inline-block;
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
/* Small upward nudge: flex centers on line-box mid, but caps-only text
reads centered around cap-height mid which sits a touch higher. */
margin-top: -0.08em;
animation: rec-blink 1.6s ease-in-out infinite;
}
.hud--onair .led { background: var(--onair); box-shadow: 0 0 10px var(--onair); }
.hud--offair .led {
background: var(--offair);
box-shadow: 0 0 10px var(--offair);
animation: rec-blink 2.4s ease-in-out infinite;
}
/* The / glyphs in DejaVu Sans Mono are designed centered on x-height,
not cap-height, so against all-caps SIGNAL their visual middle sits low.
Lift by ~(cap-mid x-mid) 0.10em to put the glyph center on cap center. */
#signal {
display: inline-block;
transform: translateY(-0.10em);
}
@keyframes rec-blink {
0%, 55% { opacity: 1; }
65%, 100% { opacity: 0.2; }
}
/* ───────── Landing-specific station identifier (top HUD) ───────── */
.station {
display: inline-flex;
align-items: baseline;
gap: 0;
white-space: pre;
}
.station .sep { color: var(--hud-dim); margin: 0 0.45em; letter-spacing: 0.32em; }
.station .rig {
display: inline-block;
color: var(--hud);
text-shadow: 0 0 10px rgba(79, 210, 255, 0.55);
font-variant-numeric: tabular-nums;
}
.station .rig::before,
.station .rig::after {
color: var(--hud-dim);
font-weight: 700;
text-shadow: none;
}
.station .rig::before { content: '['; margin-right: 0.32em; }
.station .rig::after { content: ']'; margin-left: 0.32em; }
.station .rig.glitching {
color: var(--warn);
text-shadow: 0 0 12px rgba(255, 208, 0, 0.5);
}
/* CRT overlays
Always last in the DOM so they sit above the page content. */
#static {
position: absolute; inset: 0;
width: 100%; height: 100%;
pointer-events: none;
opacity: 0.045;
mix-blend-mode: screen;
image-rendering: pixelated;
}
.scanlines {
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.20) 3px,
rgba(0,0,0,0.20) 4px
);
opacity: 0.5;
mix-blend-mode: multiply;
}
.vignette {
position: absolute; inset: 0;
pointer-events: none;
background: radial-gradient(
ellipse at center,
rgba(0,0,0,0) 45%,
rgba(0,0,0,0.60) 100%
);
}
.flicker {
position: absolute; inset: 0;
pointer-events: none;
background: rgba(0,0,0,0);
animation: flicker 11s ease-in-out infinite;
}
@keyframes flicker {
0%, 100% { background: rgba(0,0,0,0); }
18% { background: rgba(0,0,0,0); }
18.3% { background: rgba(0,0,0,0.06); }
18.5% { background: rgba(0,0,0,0); }
47% { background: rgba(0,0,0,0); }
47.3% { background: rgba(0,0,0,0.08); }
47.5% { background: rgba(0,0,0,0); }
78% { background: rgba(0,0,0,0); }
78.4% { background: rgba(0,0,0,0.05); }
78.7% { background: rgba(0,0,0,0); }
}
/* Terminal block
Used by loading, goodbye, music-box (full + nc variant). */
.terminal {
position: relative;
overflow: hidden;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 6px;
padding: 28px 36px;
font-family: var(--mono);
font-size: 26px;
line-height: 1.55;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
box-shadow:
0 0 60px rgba(80, 220, 100, 0.10),
inset 0 0 90px rgba(0, 30, 0, 0.65);
display: flex;
flex-direction: column;
}
.terminal::after {
content: '';
position: absolute; inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0) 0px,
rgba(0,0,0,0) 2px,
rgba(0,0,0,0.28) 3px,
rgba(0,0,0,0.28) 4px
);
opacity: 0.55;
mix-blend-mode: multiply;
}
/* Terminal output buffer — bottom-anchored, scrolls off the top. */
#termOutput {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
overflow: hidden;
}
.term-line { white-space: pre-wrap; word-break: break-word; }
.term-prompt { color: var(--term-fg-bright); }
.term-out { color: var(--term-fg); }
.term-dim { color: var(--term-fg-dim); }
.term-warn { color: var(--warn); }
.term-cursor {
display: inline-block;
width: 0.55em;
height: 1em;
background: var(--term-fg);
margin-left: 6px;
vertical-align: -0.15em;
box-shadow: 0 0 8px var(--term-glow);
animation: term-blink 1.05s steps(2) infinite;
}
@keyframes term-blink { 50% { opacity: 0; } }
.pin-spacer { height: 14px; }
.np-line,
.term-now-playing { margin-top: 10px; }
.np-line .np-arrow,
.term-now-playing .np-arrow {
color: var(--term-fg-bright);
margin-right: 12px;
display: inline-block;
animation: np-pulse 1.05s ease-in-out infinite alternate;
}
.np-line .np-title,
.term-now-playing .np-title { color: var(--term-fg-bright); }
.np-line .np-time,
.term-now-playing .np-time { color: var(--term-fg-dim); margin-left: 14px; }
@keyframes np-pulse {
0% { opacity: 0.45; }
100% { opacity: 1.00; }
}
.next-line .next-arrow { color: var(--term-fg-dim); margin-right: 10px; }
.next-line .next-label { color: var(--term-fg-dim); margin-right: 6px; }
.next-line .next-title { color: var(--term-fg); }
.next-line .next-title.empty { color: var(--term-fg-dim); }
/* ───────── Standby / banner ───────── */
.standby { text-align: center; }
.standby .big {
font-size: 130px;
font-weight: 900;
letter-spacing: 0.20em;
line-height: 1;
text-shadow: 0 0 18px rgba(79, 210, 255, 0.18);
animation: standby-pulse 3s ease-in-out infinite;
}
.standby .sub {
margin-top: 30px;
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.45em;
color: var(--hud-dim);
}
@keyframes standby-pulse {
0%, 100% { opacity: 0.90; }
50% { opacity: 1.00; }
}
.banner { text-align: center; }
.banner .label {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
margin-bottom: 12px;
}
.banner .title {
font-family: var(--mono);
font-size: 116px;
font-weight: 900;
letter-spacing: 0.18em;
text-indent: 0.18em;
line-height: 1;
color: var(--ink);
text-shadow:
0 0 22px rgba(255, 111, 90, 0.18),
0 0 48px rgba(230, 58, 46, 0.10);
animation: sign-pulse 3.6s ease-in-out infinite;
}
.banner.banner--green .title {
font-size: 88px;
text-shadow:
0 0 22px rgba(95, 220, 98, 0.18),
0 0 48px rgba(80, 200, 100, 0.10);
}
.banner .sub {
margin-top: 18px;
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
}
@keyframes sign-pulse {
0%, 100% { opacity: 0.88; }
50% { opacity: 1.00; }
}
/* Countdown (loading) */
.countdown { text-align: center; }
.countdown .label {
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
margin-bottom: 12px;
}
.countdown .digits {
font-family: var(--mono);
font-size: 132px;
font-weight: 900;
letter-spacing: 0.10em;
text-indent: 0.10em;
line-height: 1;
color: var(--ink);
text-shadow: 0 0 22px rgba(79, 210, 255, 0.18);
animation: cd-pulse 3s ease-in-out infinite;
}
.countdown .sub {
margin-top: 18px;
font-family: var(--mono);
font-size: 22px;
letter-spacing: 0.45em;
text-indent: 0.45em;
color: var(--hud-dim);
}
@keyframes cd-pulse {
0%, 100% { opacity: 0.92; }
50% { opacity: 1.00; }
}
.countdown.ready .digits {
color: var(--term-fg);
text-shadow: 0 0 32px var(--term-glow);
letter-spacing: 0.18em;
text-indent: 0.18em;
}
.countdown.ready .sub { color: var(--term-fg-bright); }
.countdown.ready .label { color: var(--term-fg-dim); }
/* ───────── Foot (3-col) ───────── */
.foot {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 19px;
color: var(--hud-dim);
letter-spacing: 0.12em;
}
.foot > *:nth-child(1) { justify-self: start; }
.foot > *:nth-child(2) { justify-self: center; }
.foot > *:nth-child(3) { justify-self: end; }
.foot .warn { color: var(--warn); }
.foot .onair { color: var(--onair); }
.foot .offair { color: var(--offair); }
#rig.glitching {
color: var(--warn);
text-shadow: 0 0 10px rgba(255, 208, 0, 0.4);
}
/* ───────── Status strip (chat help — music-box / nc) ───────── */
.status-strip {
display: flex;
align-items: center;
justify-content: center;
gap: 28px;
font-family: var(--mono);
font-size: 18px;
letter-spacing: 0.18em;
color: var(--hud-dim);
padding: 10px 24px;
border-top: 1px solid rgba(79, 210, 255, 0.22);
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
background: rgba(7, 8, 13, 0.55);
}
.status-strip .label { color: var(--hud); letter-spacing: 0.32em; }
.status-strip .cmd-group { display: inline-flex; gap: 18px; }
.status-strip .cmd {
color: var(--term-fg-bright);
text-shadow: 0 0 6px var(--term-glow);
}
.status-strip .desc {
color: var(--hud-dim);
margin-left: 6px;
font-size: 16px;
letter-spacing: 0.10em;
}
/* ───────── Camera + screen frame (game / desktop) ───────── */
.camera-frame,
.screen-frame {
position: absolute;
pointer-events: none;
}
.camera-frame {
/* The four --cam-* vars are auto-synced from the OBS scene item transform.
Defaults set in the page <style> for first paint. */
top: calc(var(--cam-y) - var(--cam-pad));
left: calc(var(--cam-x) - var(--cam-pad));
width: calc(var(--cam-w) + var(--cam-pad) * 2);
height: calc(var(--cam-h) + var(--cam-pad) * 2);
background: rgba(6, 10, 18, 0.85);
border: 1.5px solid rgba(79, 210, 255, 0.55);
box-shadow:
0 0 28px rgba(79, 210, 255, 0.20),
inset 0 0 60px rgba(0, 0, 0, 0.55);
}
.screen-frame {
top: calc(var(--scr-y) - var(--scr-pad));
left: calc(var(--scr-x) - var(--scr-pad));
width: calc(var(--scr-w) + var(--scr-pad) * 2);
height: calc(var(--scr-h) + var(--scr-pad) * 2);
background: transparent;
border: 1px solid rgba(79, 210, 255, 0.30);
box-shadow: 0 0 18px rgba(79, 210, 255, 0.08);
}
.camera-frame .tick,
.screen-frame .tick {
position: absolute;
width: 24px; height: 24px;
border: 3px solid var(--hud-edge);
box-shadow: 0 0 8px var(--hud-glow);
}
.camera-frame .tick.tl, .screen-frame .tick.tl { top: -2px; left: -2px; border-right: none; border-bottom: none; }
.camera-frame .tick.tr, .screen-frame .tick.tr { top: -2px; right: -2px; border-left: none; border-bottom: none; }
.camera-frame .tick.bl, .screen-frame .tick.bl { bottom: -2px; left: -2px; border-right: none; border-top: none; }
.camera-frame .tick.br, .screen-frame .tick.br { bottom: -2px; right: -2px; border-left: none; border-top: none; }
.camera-frame .label,
.screen-frame .label {
position: absolute;
top: 10px; left: 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--hud);
letter-spacing: 0.32em;
text-shadow: 0 0 4px rgba(7, 8, 13, 0.9);
}
.camera-frame .placeholder {
position: absolute; inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 14px;
font-family: var(--mono);
color: var(--hud);
pointer-events: none;
}
.camera-frame .placeholder svg { opacity: 0.6; }
.camera-frame .placeholder .ph-text {
font-size: 13px;
letter-spacing: 0.45em;
color: rgba(79, 210, 255, 0.7);
}
.camera-frame .placeholder .ph-coords {
font-size: 11px;
letter-spacing: 0.20em;
color: var(--hud-dim);
}
.camera-frame.off,
.screen-frame.off { display: none; }
/* ───────── Camera-HUD bottom status bar (game / desktop) ───────── */
.status-bar {
position: absolute;
bottom: 0;
left: 0; right: 0;
padding: 22px 72px 26px;
background:
linear-gradient(
to top,
rgba(7, 8, 13, 0.96) 0%,
rgba(7, 8, 13, 0.96) 65%,
rgba(7, 8, 13, 0.50) 90%,
rgba(7, 8, 13, 0) 100%
);
border-top: 1px solid rgba(79, 210, 255, 0.28);
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.5);
font-family: var(--mono);
letter-spacing: 0.08em;
}
.status-line { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.status-line + .status-line { margin-top: 8px; }
.status-line .sep { color: var(--hud-dim); }
.status-line .spacer { flex: 1 1 auto; }
.live-block {
display: flex; align-items: center; gap: 10px;
color: var(--accent);
font-weight: 700;
letter-spacing: 0.14em;
font-size: 18px;
}
.live-dot {
width: 11px; height: 11px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 10px var(--accent);
animation: rec-blink 1.6s ease-in-out infinite;
}
.scene-name,
.game-name {
color: var(--ink);
font-weight: 700;
font-size: 22px;
letter-spacing: 0.10em;
}
.game-mode {
color: var(--hud);
font-size: 17px;
letter-spacing: 0.18em;
}
.hud-tag {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--hud);
font-weight: 700;
font-size: 16px;
letter-spacing: 0.08em;
text-shadow: 0 0 6px rgba(7, 8, 13, 0.9);
}
.hud-tag .dim { color: var(--hud-dim); }
.meta-line {
color: var(--ink-dim);
font-size: 16px;
}
.meta-line .icon { color: var(--hud); }
.np-block { display: flex; align-items: center; gap: 10px; visibility: hidden; }
.np-block.on { visibility: visible; }
.np-block .arrow {
color: var(--term-fg);
animation: np-pulse 1.05s ease-in-out infinite alternate;
text-shadow: 0 0 6px rgba(80, 220, 100, 0.4);
}
.np-block .title { color: var(--ink); }
.np-block .time { color: var(--ink-dim); }

View File

20
webapp/public/index.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
// Determine if the application is in maintenance mode...
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';
$app->handleRequest(Request::capture());

View File

@@ -0,0 +1,28 @@
/* OPHI-118 — animated CRT static.
*
* If <canvas id="static"> isn't on the page (e.g. compact widgets that
* skip the noise overlay), this is a no-op.
*/
(function () {
'use strict';
document.addEventListener('DOMContentLoaded', () => {
const cv = document.getElementById('static');
if (!cv) return;
const ctx = cv.getContext('2d');
const W = 320, H = 180;
cv.width = W; cv.height = H;
const img = ctx.createImageData(W, H);
const drawNoise = () => {
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = (Math.random() * 255) | 0;
d[i] = d[i+1] = d[i+2] = v;
d[i+3] = 255;
}
ctx.putImageData(img, 0, 0);
};
drawNoise();
// 8 fps — visually almost identical to 12 fps but ~33% less paint work.
setInterval(drawNoise, 1000 / 8);
});
})();

106
webapp/public/js/hud.js Normal file
View File

@@ -0,0 +1,106 @@
/* OPHI-118 — shared HUD helpers used by every overlay.
*
* Exposes:
* - HUD.startClock(el) — UTC ticker into the given element.
* - HUD.startSignal(el, opts) — signal-bar fluctuator.
* - HUD.scrambleReveal(...) — cyberpunk decode/scramble used by landing.
*
* Designed to no-op if the target element isn't on the page, so the
* base layout can include it unconditionally.
*/
(function (root) {
'use strict';
const pad = n => String(n).padStart(2, '0');
function startClock(el) {
if (!el) return;
const tick = () => {
const d = new Date();
el.textContent =
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
};
tick();
setInterval(tick, 1000);
}
// n thresholds default to the original landing/loading/game distribution:
// r < .06 → 2 bars (occasional drop)
// r < .70 → 3 bars (typical)
// r < .95 → 4 bars
// → 5 bars (occasional spike)
function startSignal(el, opts) {
if (!el) return;
const profile = opts?.profile || 'normal';
const FILL = '▮', EMPTY = '▯';
const draw = n => FILL.repeat(n) + EMPTY.repeat(5 - n);
setInterval(() => {
const r = Math.random();
let n;
if (profile === 'strong') {
n = r < 0.15 ? 3 : r < 0.65 ? 4 : 5;
} else if (profile === 'degrading') {
n = r < 0.45 ? 1 : r < 0.85 ? 2 : r < 0.97 ? 3 : 0;
} else {
n = r < 0.06 ? 2 : r < 0.70 ? 3 : r < 0.95 ? 4 : 5;
}
el.textContent = draw(n);
}, 1500);
}
// ASCII-only glyph pool — DejaVu Mono renders these at 1ch each, so the
// intermediate frames don't shift width.
const GLITCH_GLYPHS = '01ABCDEFGHIJKLMNOPQRSTUVWXYZ#@*?!§%';
const glyph = () => GLITCH_GLYPHS[(Math.random() * GLITCH_GLYPHS.length) | 0];
const _scrambleHandles = new WeakMap();
function scrambleReveal(el, finalText, opts) {
opts = opts || {};
const { scrambleMs = 360, stepMs = 50, resolveStepMs = 60 } = opts;
const prev = _scrambleHandles.get(el);
if (prev) { clearInterval(prev.s); clearInterval(prev.r); }
const len = finalText.length;
if (!len) {
el.textContent = '';
el.classList.remove('glitching');
_scrambleHandles.delete(el);
return;
}
el.classList.add('glitching');
const scrambleSteps = Math.max(1, Math.floor(scrambleMs / stepMs));
const handle = { s: null, r: null };
_scrambleHandles.set(el, handle);
let step = 0;
handle.s = setInterval(() => {
let s = '';
for (let i = 0; i < len; i++) s += glyph();
el.textContent = s;
if (++step < scrambleSteps) return;
clearInterval(handle.s); handle.s = null;
let resolved = 0;
handle.r = setInterval(() => {
resolved++;
let s = finalText.slice(0, resolved);
for (let i = resolved; i < len; i++) s += glyph();
el.textContent = s;
if (resolved < len) return;
clearInterval(handle.r); handle.r = null;
el.textContent = finalText;
el.classList.remove('glitching');
_scrambleHandles.delete(el);
}, resolveStepMs);
}, stepMs);
}
root.HUD = { pad, startClock, startSignal, scrambleReveal };
// Auto-bind: every overlay has #clock and #signal in the shared partial.
document.addEventListener('DOMContentLoaded', () => {
startClock(document.getElementById('clock'));
const sigEl = document.getElementById('signal');
if (sigEl) {
const profile = sigEl.dataset.profile || 'normal';
startSignal(sigEl, { profile });
}
});
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,46 @@
/* OPHI-118 — OBS WebSocket connection helper with auto-reconnect.
*
* Most overlays connect to OBS WS to receive `mpd:state` events. This
* wraps the connect-then-retry-on-close dance so each page doesn't
* repeat it inline.
*
* OBSWSBootstrap.onState(handler, { onClose, onConnect })
* OBSWSBootstrap.connect({ onConnect, onClose })
*
* Both forms return the underlying OBSWSMini instance via the promise
* resolved by onConnect (or directly from connect()).
*/
(function (root) {
'use strict';
function _connect(opts) {
const { onConnect, onClose } = opts || {};
if (!root.__OBSWS || !root.OBSWSMini) {
onClose && onClose(new Error('vendor/obs-config.js missing'));
return;
}
const obs = new root.OBSWSMini(root.__OBSWS.url, root.__OBSWS.password);
obs.connect().then(() => {
onConnect && onConnect(obs);
}).catch((err) => {
onClose && onClose(err);
setTimeout(() => _connect(opts), 2500);
});
obs.addEventListener('close', () => {
onClose && onClose(new Error('connection closed'));
setTimeout(() => _connect(opts), 2000);
});
}
function onState(handler, opts) {
_connect({
onConnect: (obs) => {
obs.onCustom('mpd:state', handler);
opts?.onConnect && opts.onConnect(obs);
},
onClose: opts?.onClose,
});
}
root.OBSWSBootstrap = { connect: _connect, onState };
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,237 @@
// Minimal OBS WebSocket v5 client (JSON variant).
// Just what the music daemon + loading overlay need:
// - connect with optional auth (HMAC-SHA256 challenge)
// - call('BroadcastCustomEvent', { eventData: { ... } })
// - onCustom(cb) → fires for incoming CustomEvent broadcasts
//
// Protocol reference:
// https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
//
// Exposes: window.OBSWSMini
(function (root) {
'use strict';
// Pure-JS SHA-256 (FIPS 180-4) → base64. Used when crypto.subtle is
// unavailable — e.g. OBS's CEF browser source, where file:// URLs do
// not grant secure-context status, so window.crypto.subtle is undefined.
// Input is treated as a JS string and encoded UTF-8 before hashing.
function _sha256b64Pure(str) {
const bytes = [];
for (let i = 0; i < str.length; i++) {
let c = str.charCodeAt(i);
if (c < 0x80) bytes.push(c);
else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
else if (c < 0xd800 || c >= 0xe000) {
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
} else {
i++;
c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f),
0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
}
}
const bitLen = bytes.length * 8;
bytes.push(0x80);
while (bytes.length % 64 !== 56) bytes.push(0);
const high = Math.floor(bitLen / 0x100000000);
const low = bitLen >>> 0;
for (let i = 3; i >= 0; i--) bytes.push((high >>> (i * 8)) & 0xff);
for (let i = 3; i >= 0; i--) bytes.push((low >>> (i * 8)) & 0xff);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const K = [
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
];
const ROTR = (x, n) => (x >>> n) | (x << (32 - n));
const W = new Uint32Array(64);
for (let block = 0; block < bytes.length; block += 64) {
for (let i = 0; i < 16; i++) {
W[i] = ((bytes[block + i*4] << 24) |
(bytes[block + i*4+1] << 16) |
(bytes[block + i*4+2] << 8) |
bytes[block + i*4+3]) >>> 0;
}
for (let i = 16; i < 64; i++) {
const s0 = ROTR(W[i-15], 7) ^ ROTR(W[i-15], 18) ^ (W[i-15] >>> 3);
const s1 = ROTR(W[i-2], 17) ^ ROTR(W[i-2], 19) ^ (W[i-2] >>> 10);
W[i] = (W[i-16] + s0 + W[i-7] + s1) >>> 0;
}
let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
for (let i = 0; i < 64; i++) {
const S1 = ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25);
const ch = (e & f) ^ (~e & g);
const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
const S0 = ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22);
const mj = (a & b) ^ (a & c) ^ (b & c);
const t2 = (S0 + mj) >>> 0;
h = g; g = f; f = e; e = (d + t1) >>> 0;
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
}
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
}
let bin = '';
for (let i = 0; i < 8; i++) {
bin += String.fromCharCode((H[i]>>>24)&0xff, (H[i]>>>16)&0xff, (H[i]>>>8)&0xff, H[i]&0xff);
}
return btoa(bin);
}
class OBSWSMini extends EventTarget {
constructor(url, password) {
super();
this.url = url;
this.password = password || '';
this._reqId = 0;
this._pending = new Map();
this.ws = null;
this.identified = false;
}
async _sha256b64(str) {
// Prefer WebCrypto when available (HTTPS/localhost contexts).
if (typeof crypto !== 'undefined' && crypto.subtle && crypto.subtle.digest) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
const bytes = new Uint8Array(buf);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
return _sha256b64Pure(str);
}
async _authString(salt, challenge) {
const secret = await this._sha256b64(this.password + salt);
return await this._sha256b64(secret + challenge);
}
connect() {
return new Promise((resolve, reject) => {
let identified = false;
let settled = false;
const settle = (fn, val) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
fn(val);
};
// Hard timeout — if the server never sends Identified within 10s,
// give up so the caller can show an error instead of hanging.
const timeout = setTimeout(() => {
if (!identified) {
try { this.ws?.close(); } catch {}
settle(reject, new Error('connect timeout (no Identified within 10s)'));
}
}, 10000);
this.ws = new WebSocket(this.url);
this.ws.onopen = () => console.log('[OBSWS] socket open →', this.url);
this.ws.onerror = () => settle(reject, new Error('ws error'));
this.ws.onclose = (ev) => {
this.identified = false;
console.log(`[OBSWS] socket closed (code=${ev.code} reason='${ev.reason || ''}' clean=${ev.wasClean})`);
if (!identified) {
settle(reject, new Error(`closed before Identified (code=${ev.code} reason='${ev.reason || 'none'}')`));
}
this.dispatchEvent(new Event('close'));
};
this.ws.onmessage = async (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch { return; }
console.log('[OBSWS] ←', m.op, m.d?.eventType || m.d?.requestType || '');
try {
if (m.op === 0) { // Hello
let auth;
if (m.d.authentication) {
if (!this.password) {
return settle(reject, new Error('server requires password'));
}
auth = await this._authString(m.d.authentication.salt, m.d.authentication.challenge);
}
const identifyMsg = {
op: 1,
d: { rpcVersion: 1, authentication: auth, eventSubscriptions: 0xFFFFFFFF },
};
console.log('[OBSWS] → 1 (Identify, auth-len=' + (auth?.length || 0) + ')');
this.ws.send(JSON.stringify(identifyMsg));
} else if (m.op === 2) { // Identified
identified = true;
this.identified = true;
settle(resolve, this);
this.dispatchEvent(new Event('identified'));
} else if (m.op === 5) { // Event
this.dispatchEvent(new CustomEvent('event', { detail: m.d }));
if (m.d.eventType === 'CustomEvent') {
this.dispatchEvent(new CustomEvent('custom', { detail: m.d.eventData || {} }));
}
} else if (m.op === 7) { // RequestResponse
const p = this._pending.get(m.d.requestId);
if (!p) return;
this._pending.delete(m.d.requestId);
if (m.d.requestStatus && m.d.requestStatus.result) {
p.resolve(m.d.responseData || {});
} else {
p.reject(new Error(m.d.requestStatus?.comment || 'request failed'));
}
}
} catch (e) {
console.error('[OBSWS] message handler error:', e);
settle(reject, new Error(`message handler error: ${e.message}`));
}
};
});
}
call(requestType, requestData) {
if (!this.identified) return Promise.reject(new Error('not identified'));
const requestId = `r_${++this._reqId}`;
return new Promise((resolve, reject) => {
this._pending.set(requestId, { resolve, reject });
this.ws.send(JSON.stringify({
op: 6,
d: { requestType, requestId, requestData: requestData || {} },
}));
setTimeout(() => {
if (this._pending.has(requestId)) {
this._pending.delete(requestId);
reject(new Error('request timeout'));
}
}, 5000);
});
}
// Convenience: broadcast a namespaced custom event to all OBS WS clients.
broadcast(eventType, payload) {
return this.call('BroadcastCustomEvent', {
eventData: Object.assign({ _type: eventType }, payload || {}),
});
}
// Convenience: subscribe to incoming custom events. Filtered by _type if given.
onCustom(typeOrCb, cb) {
const filter = typeof typeOrCb === 'string' ? typeOrCb : null;
const fn = filter ? cb : typeOrCb;
this.addEventListener('custom', (e) => {
const d = e.detail || {};
if (filter && d._type !== filter) return;
fn(d);
});
}
}
root.OBSWSMini = OBSWSMini;
})(typeof window !== 'undefined' ? window : globalThis);

2
webapp/public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

View File

@@ -0,0 +1,11 @@
@import 'tailwindcss';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

View File

@@ -0,0 +1 @@
//

View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / overlays</title>
<style>
body { background:#0a0e0a; color:#5fdc62; font:14px/1.6 monospace; padding:24px; }
a { color:#97f99a; }
h1 { color:#4fd2ff; letter-spacing:.2em; font-size:14px; margin-bottom:12px; }
ul { list-style:none; padding:0; }
li { padding:3px 0; }
small { color:#2c8d2f; }
</style>
</head>
<body>
<h1>OPHI-118 SCENE OVERLAY APP</h1>
<ul>
<li><a href="/landing">/landing</a> <small> stand-by</small></li>
<li><a href="/loading">/loading</a> <small> project load + countdown</small></li>
<li><a href="/game">/game</a> <small> game HUD (camera-tracked)</small></li>
<li><a href="/desktop">/desktop</a> <small> desktop HUD (camera + screen)</small></li>
<li><a href="/goodbye">/goodbye</a> <small> sign-off</small></li>
<li><a href="/music-box">/music-box</a> <small> full music box</small></li>
<li><a href="/music-box/widget">/music-box/widget</a> <small> 549×880 widget</small></li>
<li><a href="/music-box/cover">/music-box/cover</a> <small> cover art</small></li>
<li><a href="/music-box/cover?bars=0">/music-box/cover?bars=0</a> <small> compact cover</small></li>
<li><a href="/music-box/nc">/music-box/nc</a> <small> ncurses-styled music box</small></li>
<li><a href="/music-daemon">/music-daemon</a> <small> audio worker</small></li>
<li>&nbsp;</li>
<li><a href="/data/playlist.js">/data/playlist.js</a> <small> window.__PLAYLIST</small></li>
<li><a href="/cover.jpg">/cover.jpg</a> <small> album art</small></li>
</ul>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / @yield('title', 'OVERLAY')</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
@yield('styles')
</head>
<body class="@yield('body-class', 'overlay-body')">
@yield('content')
@hasSection('crt')
@yield('crt')
@else
@include('partials.crt-overlays')
@endif
@stack('data-scripts')
@include('partials.obs-ws-scripts')
<script src="{{ asset('js/hud.js') }}"></script>
<script src="{{ asset('js/crt-static.js') }}"></script>
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
@yield('scripts')
</body>
</html>

View File

@@ -0,0 +1,148 @@
@extends('layouts.overlay')
@section('title', 'DESKTOP')
@section('body-class', 'camera-hud-body')
@section('styles')
<style>
:root {
--cam-x: 1937px; --cam-y: 98px; --cam-w: 549px; --cam-h: 309px; --cam-pad: 10px;
--scr-x: 10px; --scr-y: 14px; --scr-w: 1976px; --scr-h: 1209px; --scr-pad: 0px;
}
#signal { transform: translateY(-0.10em); }
</style>
@endsection
@section('crt')
{{-- transparent overlay no CRT overlays --}}
@endsection
@section('content')
@include('partials.screen-frame', ['id' => 'scr', 'label' => 'SCR 01'])
@include('partials.camera-frame', ['id' => 'cam', 'label' => 'CAM 01'])
<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">OPHI-118 // DESKTOP</span>
<span class="spacer"></span>
<span class="hud-tag"><span class="dim">SIGNAL</span><span id="signal" data-profile="normal">▮▮▮▯▯</span></span>
<span class="sep">·</span>
<span class="hud-tag"><span class="dim">UTC</span><span id="clock">--:--:--</span></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>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
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);
const params = new URLSearchParams(location.search);
const CAM_SOURCE = params.get('camera') || 'Camera';
const SCR_SOURCE = params.get('screen') || 'Screen Capture (PipeWire)';
const SCENE_LOCK = params.get('scene') || null;
const camEl = document.getElementById('cam');
const scrEl = document.getElementById('scr');
const rootStyle = document.documentElement.style;
function renderedSize(t) {
const useBounds = t.boundsType
&& t.boundsType !== 'OBS_BOUNDS_NONE'
&& (t.boundsWidth ?? 0) > 0;
if (useBounds) return { w: t.boundsWidth, h: t.boundsHeight };
return {
w: (t.sourceWidth ?? 0) * (t.scaleX ?? 1),
h: (t.sourceHeight ?? 0) * (t.scaleY ?? 1),
};
}
function makeFrame(obs, sourceName, frameEl, varPrefix) {
const state = { sceneName: null, itemId: null };
const setVisible = (v) => frameEl.classList.toggle('off', !v);
const setTransform = (t) => {
if (!t) return;
const { w, h } = renderedSize(t);
if (!(w > 0 && h > 0)) return;
rootStyle.setProperty(`--${varPrefix}-x`, `${t.positionX}px`);
rootStyle.setProperty(`--${varPrefix}-y`, `${t.positionY}px`);
rootStyle.setProperty(`--${varPrefix}-w`, `${w}px`);
rootStyle.setProperty(`--${varPrefix}-h`, `${h}px`);
};
return {
async rebind(sceneName) {
state.sceneName = sceneName;
state.itemId = null;
if (!sceneName) { setVisible(false); return; }
try {
const r = await obs.call('GetSceneItemId', { sceneName, sourceName });
state.itemId = r.sceneItemId;
const e = await obs.call('GetSceneItemEnabled', { sceneName, sceneItemId: state.itemId });
setVisible(e.sceneItemEnabled);
const t = await obs.call('GetSceneItemTransform', { sceneName, sceneItemId: state.itemId });
setTransform(t.sceneItemTransform);
} catch {
setVisible(false);
}
},
handleEvent(d) {
if (d.eventData?.sceneName !== state.sceneName
|| d.eventData?.sceneItemId !== state.itemId) return;
if (d.eventType === 'SceneItemEnableStateChanged') {
setVisible(d.eventData.sceneItemEnabled);
} else if (d.eventType === 'SceneItemTransformChanged') {
setTransform(d.eventData.sceneItemTransform);
}
},
};
}
OBSWSBootstrap.connect({
onConnect: async (obs) => {
const camFrame = makeFrame(obs, CAM_SOURCE, camEl, 'cam');
const scrFrame = makeFrame(obs, SCR_SOURCE, scrEl, 'scr');
let scene = SCENE_LOCK;
if (!scene) {
try {
const r = await obs.call('GetCurrentProgramScene');
scene = r.currentProgramSceneName || r.sceneName || null;
} catch { /* leave null */ }
}
await Promise.all([camFrame.rebind(scene), scrFrame.rebind(scene)]);
obs.addEventListener('event', async (ev) => {
const d = ev.detail || {};
if (!SCENE_LOCK && d.eventType === 'CurrentProgramSceneChanged') {
const next = d.eventData?.sceneName;
await Promise.all([camFrame.rebind(next), scrFrame.rebind(next)]);
return;
}
camFrame.handleEvent(d);
scrFrame.handleEvent(d);
});
},
});
</script>
@endsection

View File

@@ -0,0 +1,175 @@
@extends('layouts.overlay')
@section('title', 'GAME')
@section('body-class', 'camera-hud-body')
@section('styles')
<style>
:root {
--pad-y: 40px;
--pad-x: 72px;
/* Camera frame defaults — auto-synced from OBS via WS. */
--cam-x: 1855px;
--cam-y: 128px;
--cam-w: 580px;
--cam-h: 326px;
--cam-pad: 10px;
}
.hud {
position: absolute;
top: var(--pad-y);
left: var(--pad-x);
right: var(--pad-x);
font-size: 20px;
text-shadow: 0 0 8px rgba(7, 8, 13, 0.9), 0 0 4px rgba(7, 8, 13, 0.9);
}
.hud .group { gap: 24px; }
.hud .group > span { gap: 10px; }
.hud .led { width: 10px; height: 10px; }
</style>
@endsection
@push('data-scripts')
<script>window.__LOADING = @json($manifest);</script>
@endpush
@section('crt')
{{-- no CRT overlays on the transparent game HUD --}}
@endsection
@section('content')
@include('partials.hud-strip', [
'variant' => 'live',
'idText' => 'OPHI-118 // LIVE',
])
@include('partials.camera-frame', ['id' => 'cam', 'label' => 'CAM 01'])
<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="game-name" id="game-name"></span>
<span class="sep" id="game-mode-sep">·</span>
<span class="game-mode" id="game-mode"></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" id="mic-icon" title="microphone">🎙</span>
<span id="mic-state"></span>
<span class="sep">·</span>
<div class="np-block" id="np">
<span class="arrow"></span>
<span class="title" id="np-title"></span>
<span class="time" id="np-time">[--:-- / --:--]</span>
</div>
</div>
</footer>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
const m = window.__LOADING || {};
const gameName = (m.game || '—').toUpperCase();
const subtitle = m.subtitle ? m.subtitle.toUpperCase() : null;
const cameraOn = m.camera ?? true;
const micOn = m.microphone ?? true;
document.getElementById('game-name').textContent = gameName;
if (subtitle) {
document.getElementById('game-mode').textContent = subtitle;
} else {
document.getElementById('game-mode-sep').style.display = 'none';
document.getElementById('game-mode').style.display = 'none';
}
const micEl = document.getElementById('mic-state');
micEl.textContent = micOn ? 'ON' : 'MUTED';
micEl.style.color = micOn ? 'var(--term-fg)' : 'var(--accent)';
if (!cameraOn) document.getElementById('cam').classList.add('off');
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);
const params = new URLSearchParams(location.search);
const CAM_SOURCE = params.get('camera') || 'Camera';
const CAM_SCENE = params.get('scene') || 'Game';
function fmtTime(s) {
if (!isFinite(s) || s < 0) return '--:--';
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
}
const npBlock = document.getElementById('np');
const npTitle = document.getElementById('np-title');
const npTime = document.getElementById('np-time');
const camEl = document.getElementById('cam');
const rootStyle = document.documentElement.style;
const setCameraVisible = (visible) => camEl.classList.toggle('off', !visible);
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(`[game] 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);
}
});
}
OBSWSBootstrap.connect({
onConnect: async (obs) => {
obs.onCustom('mpd:state', (s) => {
if (!s.title) return;
npBlock.classList.add('on');
npTitle.textContent = s.title;
npTime.textContent = `[${fmtTime(s.currentTime)} / ${fmtTime(s.duration)}]`;
});
await syncCamera(obs);
},
onClose: () => npBlock.classList.remove('on'),
});
</script>
@endsection

View File

@@ -0,0 +1,267 @@
@extends('layouts.overlay')
@section('title', 'SIGN-OFF')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 28px;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
}
.terminal {
width: 60vw;
max-width: 1380px;
height: 58vh;
max-height: 740px;
min-height: 400px;
}
</style>
@endsection
@push('data-scripts')
<script>window.__TRACK_COUNT = @json($trackCount);</script>
<script src="{{ asset('data/playlist.js') }}"></script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'offair',
'label' => 'OFF AIR',
'idText' => 'OPHI-118 // SIGN-OFF',
'signalGlyph' => '▮▮▯▯▯',
'signalProfile' => 'degrading',
])
<main class="stage">
<div class="terminal">
<div id="termOutput"></div>
</div>
</main>
<section class="banner" id="banner">
<div class="label"> TRANSMISSION ENDED </div>
<div class="title" id="bannerTitle">GOOD&nbsp;BYE</div>
<div class="sub" id="bannerSub"> THANKS FOR TUNING IN </div>
</section>
<footer class="foot">
<span>CH 118.0 MHz</span>
<span id="rig"> PROJECT MANIFEST UNLOADED </span>
<span class="offair"> OFF AIR</span>
</footer>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const PROMPT = 'OPHI-118://> ';
const MODE_KEYS = ['repeat', 'random', 'single'];
const lines = [
{ kind: 'cmd', text: 'unloadproject --flush --persist-state' },
{ kind: 'out', text: 'closing transmission envelope...' },
{ kind: 'gap' },
{ kind: 'out', text: 'session :: archived' },
{ kind: 'out', text: 'camera :: released' },
{ kind: 'out', text: 'microphone :: released' },
{ kind: 'out', text: 'capture :: released' },
{ kind: 'gap' },
{ kind: 'dim', text: 'flushing buffers...' },
{ kind: 'gap' },
{ kind: 'out', text: 'TRANSMISSION ENDED.' },
{ kind: 'warn', text: '— see you on the next channel —' },
];
const term = document.getElementById('termOutput');
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);
}
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
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;
}
async function typeCommand(text) {
const t = append(PROMPT, '', 'term-out');
for (const c of text) {
t.textContent += c;
await sleep(38);
}
await sleep(280);
}
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatMusicCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--keep-alive'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => { if (p[key]) parts.push(`--${key}`); });
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
const playlistData = window.__PLAYLIST || { tracks: [] };
const allTracks = playlistData.tracks || [];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
let modeLogChain = Promise.resolve();
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))}`;
}
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
}
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) { lastPlaybackModes = current; return; }
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
let lastTrackFile = null;
function renderMusicState(s) {
if (!npLine) ensureNowPlayingLine();
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile) {
logBeforeNp(`[audio] next: ${s.title || '?'}`);
}
if (s.file) lastTrackFile = s.file;
npLine.querySelector('.np-title').textContent = s.title || '—';
npLine.querySelector('.np-time').textContent =
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatMusicCommand(s.playback));
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued', 'term-dim');
bootFinished = true;
return;
}
append('> ', `[audio] queue retained: ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', '[audio] subscribing to mpd:state events', 'term-out');
await sleep(180);
append('> ', '[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderMusicState(pendingState);
}
function startMusic() {
append('', '[audio] waiting for mpd state', 'term-dim');
OBSWSBootstrap.onState((s) => {
pendingState = s;
if (!bootStarted) { bootFromState(s); return; }
if (!bootFinished) return;
renderMusicState(s);
trackModeChanges(s.playback);
}, {
onClose: () => logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim'),
});
}
(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 {
const cls = ln.kind === 'dim' ? 'term-dim' : ln.kind === 'warn' ? 'term-warn' : 'term-out';
append('> ', ln.text, cls);
await sleep(170);
}
}
await sleep(400);
startMusic();
})();
// "OFFLINE FOR" counter that takes over the banner sub-line after 6s.
const startMs = Date.now();
const subEl = document.getElementById('bannerSub');
const tickOffline = () => {
const sec = Math.floor((Date.now() - startMs) / 1000);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
const t = h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
subEl.textContent = `— OFFLINE FOR ${t} —`;
};
setTimeout(() => { tickOffline(); setInterval(tickOffline, 500); }, 6000);
</script>
@endsection

View File

@@ -0,0 +1,163 @@
@extends('layouts.overlay')
@section('title', 'STAND BY')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 24px;
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 36px;
}
.mark {
width: 360px; height: 360px;
filter: drop-shadow(0 0 22px rgba(58, 209, 255, 0.35));
}
.bars {
display: flex;
width: 720px;
height: 14px;
opacity: 0.32;
}
.bars i { flex: 1; display: block; }
.bars i:nth-child(1) { background: #c7c7c7; }
.bars i:nth-child(2) { background: #c7c700; }
.bars i:nth-child(3) { background: #00c7c7; }
.bars i:nth-child(4) { background: #00c700; }
.bars i:nth-child(5) { background: #c700c7; }
.bars i:nth-child(6) { background: #c70000; }
.bars i:nth-child(7) { background: #0000c7; }
</style>
@endsection
@push('data-scripts')
<script>window.__TEL = @json($telemetry);</script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'live',
'label' => 'TRANSMISSION',
'extraIdSlot' => '<span class="station"><span class="station-id">OPHI-118</span><span class="sep">//</span><span class="rig" id="rigName">--</span></span>',
])
<main class="stage">
<svg class="mark" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<circle cx="100" cy="100" r="92" fill="none" stroke="#3ad1ff" stroke-width="1.2" opacity="0.35"/>
<circle cx="100" cy="100" r="80" fill="none" stroke="#3ad1ff" stroke-width="2.5"/>
<g stroke="#3ad1ff" stroke-width="2" opacity="0.55">
<line x1="100" y1="8" x2="100" y2="22"/>
<line x1="100" y1="178" x2="100" y2="192"/>
<line x1="8" y1="100" x2="22" y2="100"/>
<line x1="178" y1="100" x2="192" y2="100"/>
</g>
<polygon points="100,52 148,140 52,140"
fill="none" stroke="#e63a2e" stroke-width="3.5" stroke-linejoin="miter"/>
<circle cx="100" cy="116" r="4" fill="#ffd000"/>
</svg>
<div class="bars"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
</main>
<section class="standby">
<div class="big">PLEASE STAND BY</div>
<div class="sub">&mdash; DO NOT ADJUST YOUR RECEIVER &mdash;</div>
</section>
<footer class="foot">
<span>CH 118.0 MHz</span>
<span id="rig">&mdash; TELEMETRY &mdash;</span>
<span class="warn">&#x25B2; AUDIO ACTIVE</span>
</footer>
@endsection
@section('scripts')
<script>
'use strict';
const rigNameEl = document.getElementById('rigName');
const RIG_NAME = (window.__TEL && window.__TEL.rig) || 'UNKNOWN';
const upperRig = String(RIG_NAME).toUpperCase();
const scrambleRig = ({ initial = false } = {}) => {
if (!upperRig.length) { rigNameEl.textContent = '--'; return; }
HUD.scrambleReveal(rigNameEl, upperRig, {
scrambleMs: initial ? 600 : 360, stepMs: 50, resolveStepMs: 60,
});
};
setTimeout(() => scrambleRig({ initial: true }), 600);
setInterval(scrambleRig, 9000);
// Rotating telemetry footer.
const rigEl = document.getElementById('rig');
const tel = window.__TEL || null;
const shortCpu = (m) => (m || '')
.replace(/\(R\)|\(TM\)/g, '')
.replace(/\s+\S+-Core\s+Processor\b/i, '')
.replace(/\s+Processor\b/i, '')
.replace(/\s+CPU\b.*$/i, '')
.replace(/\s+@.*$/, '')
.replace(/\s+/g, ' ')
.trim();
const lines = [
() => {
if (!tel?.cpu?.model) return null;
let s = `CPU · ${shortCpu(tel.cpu.model)}`;
if (tel.cpu.threads) s += ` · ${tel.cpu.threads}T`;
if (tel.mem?.totalGB) s += ` · MEM ${tel.mem.totalGB} GB`;
return s;
},
() => {
if (!tel?.gpu?.name) return null;
let s = `GPU · ${tel.gpu.name}`;
if (tel.gpu.vramTotalMB) s += ` · ${(tel.gpu.vramTotalMB / 1024).toFixed(0)} GB VRAM`;
return s;
},
() => {
const o = tel?.obs;
if (!o?.output?.w) return null;
let s = `OUTPUT · ${o.output.w}×${o.output.h}`;
if (o.fps) s += ` @ ${o.fps} FPS`;
if (o.stream?.encoder) s += ` · ${o.stream.encoder}`;
return s;
},
() => {
const s = tel?.obs?.stream;
if (!s) return null;
const parts = [];
if (s.rateControl) parts.push(s.rateControl);
if (s.bitrateKbps) parts.push(`${s.bitrateKbps} kbps`);
if (s.profile) parts.push(s.profile.toUpperCase());
return parts.length ? `STREAM · ${parts.join(' · ')}` : null;
},
() => tel?.host?.kernel ? `KERNEL · ${tel.host.kernel}` : null,
];
let lineIdx = 0;
const nextLine = () => {
for (let i = 0; i < lines.length; i++) {
const v = lines[lineIdx]();
if (v) return v;
lineIdx = (lineIdx + 1) % lines.length;
}
return '— TELEMETRY OFFLINE —';
};
rigEl.textContent = nextLine();
const swapLine = () => {
const text = nextLine();
const len = Math.max(text.length, 1);
HUD.scrambleReveal(rigEl, text, {
scrambleMs: 280,
stepMs: 45,
resolveStepMs: Math.max(15, Math.floor(700 / len)),
});
};
setInterval(() => {
lineIdx = (lineIdx + 1) % lines.length;
swapLine();
}, 6000);
</script>
@endsection

View File

@@ -0,0 +1,294 @@
@extends('layouts.overlay')
@section('title', 'PROJECT LOADING')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto auto;
gap: 28px;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
}
.terminal {
width: 60vw;
max-width: 1380px;
height: 62vh;
max-height: 820px;
min-height: 400px;
}
</style>
@endsection
@push('data-scripts')
<script>
window.__LOADING = @json($manifest);
window.__TEL = @json($telemetry);
window.__TRACK_COUNT = @json($trackCount);
</script>
<script src="{{ asset('data/playlist.js') }}"></script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'live',
'idText' => 'OPHI-118 // PROJECT LOAD',
])
<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>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
const sleep = ms => new Promise(r => setTimeout(r, ms));
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 MODE_KEYS = ['repeat', 'random', 'single'];
const rigName = (window.__TEL?.rig || 'unknown').toUpperCase();
const lines = [
{ kind: 'cmd', text: 'loadproject --manifest' },
{ kind: 'out', text: `detected rig :: ${rigName} - initializing transmission...` },
{ 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.' },
];
const term = document.getElementById('termOutput');
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;
}
async function typeCommand(text) {
const t = append(PROMPT, '', 'term-out');
for (const c of text) {
t.textContent += c;
await sleep(38);
}
await sleep(280);
}
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatMusicCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--boot'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => {
if (p[key]) parts.push(`--${key}`);
});
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
const playlistData = window.__PLAYLIST || { tracks: [] };
const allTracks = playlistData.tracks || [];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
let modeLogChain = Promise.resolve();
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))}`;
}
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforeNp(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out');
}
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) {
lastPlaybackModes = current;
return;
}
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
let lastTrackFile = null;
function renderMusicState(s) {
if (!npLine) ensureNowPlayingLine();
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile) {
logBeforeNp(`[audio] next: ${s.title || '?'}`);
}
if (s.file) lastTrackFile = s.file;
npLine.querySelector('.np-title').textContent = s.title || '—';
npLine.querySelector('.np-time').textContent =
`[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatMusicCommand(s.playback));
if (allTracks.length === 0) {
append('> ', '[audio] no tracks queued — run `php artisan rig:playlist`', 'term-dim');
bootFinished = true;
return;
}
append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out');
await sleep(180);
append('> ', `[audio] uplink to daemon @ ophi-118://${rigName.toLowerCase()}.audio.bus`, 'term-out');
await sleep(180);
append('> ', '[audio] chat ops armed :: !skip · !queue · !info', 'term-out');
await sleep(180);
append('> ', '[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderMusicState(pendingState);
}
function startMusic() {
append('', '[audio] waiting for mpd state', 'term-dim');
OBSWSBootstrap.onState((s) => {
pendingState = s;
if (!bootStarted) {
bootFromState(s);
return;
}
if (!bootFinished) return;
renderMusicState(s);
trackModeChanges(s.playback);
}, {
onClose: () => logBeforeNp('[audio] daemon connection lost — retrying', 'term-dim'),
});
}
(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 sleep(500);
startMusic();
})();
// Countdown
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>
@endsection

View File

@@ -0,0 +1,195 @@
@extends('layouts.overlay')
@section('title', 'MUSIC BOX')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto auto auto;
gap: 22px;
}
.stage {
display: flex;
align-items: center;
justify-content: center;
}
.terminal {
width: 64vw;
max-width: 1480px;
height: 60vh;
max-height: 760px;
min-height: 420px;
font-size: 24px;
}
</style>
@endsection
@push('data-scripts')
<script>window.__TRACK_COUNT = @json($trackCount);</script>
<script src="{{ asset('data/playlist.js') }}"></script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'onair',
'label' => 'ON AIR',
'idText' => 'OPHI-118 // MUSIC',
'signalGlyph' => '▮▮▮▮▯',
'signalProfile' => 'strong',
])
<main class="stage">
<div class="terminal">
<div id="termOutput"><div id="pinnedBlock"><div class="pin-spacer"></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">next:</span><span class="next-title empty" data-slot="0"> end of queue </span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="1"></span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="2"></span></div><div class="pin-spacer"></div><div class="term-line np-line"><span class="np-arrow"></span><span class="np-title">connecting to daemon…</span><span class="np-time">[--:-- / --:--]</span></div></div></div>
</div>
</main>
<section class="status-strip">
<span class="label">CHAT</span>
<span class="cmd-group">
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
</span>
</section>
<section class="banner banner--green">
<div class="label"> TRANSMISSION OPEN </div>
<div class="title">MUSIC&nbsp;BOX</div>
<div class="sub"> STAY TUNED </div>
</section>
<footer class="foot">
<span>CH 118.0 MHz</span>
<span id="rig"> LOADING PLAYLIST </span>
<span class="onair"> ON AIR</span>
</footer>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
const playlistData = window.__PLAYLIST || { tracks: [] };
const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0);
document.getElementById('rig').textContent =
trackCount > 0 ? `LIBRARY :: ${trackCount} TRACKS` : '— EMPTY LIBRARY —';
const sleep = ms => new Promise(r => setTimeout(r, ms));
const term = document.getElementById('termOutput');
const pinnedBlock = document.getElementById('pinnedBlock');
const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200;
function pruneTerminal() {
while (term.children.length > MAX_TERM_LINES) {
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
}
function logBeforePins(text, cls = 'term-out', prompt = '> ') {
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.insertBefore(div, pinnedBlock);
pruneTerminal();
}
async function typeCommand(text) {
const div = document.createElement('div');
div.className = 'term-line';
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = PROMPT;
div.appendChild(p);
const body = document.createElement('span');
body.className = 'term-out';
div.appendChild(body);
term.insertBefore(div, pinnedBlock);
for (const c of text) {
body.textContent += c;
await sleep(38);
}
await sleep(280);
}
function fmtClock(s) {
if (!isFinite(s) || s < 0) return '--:--';
return `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
}
let lastTrackFile = null;
function applyState(s) {
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile && s.title) {
logBeforePins(`[audio] now: ${s.title}`);
}
if (s.file) lastTrackFile = s.file;
npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
nextSlots.forEach((slot, i) => {
const v = titles[i];
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
} else {
slot.textContent = i === 0 ? '— end of queue —' : '—';
slot.classList.add('empty');
}
});
pruneTerminal();
}
function setOffline(msg) {
npTitle.textContent = msg;
npTime.textContent = '[--:-- / --:--]';
}
function startMusic() {
OBSWSBootstrap.onState(applyState, {
onClose: () => {
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
setOffline('daemon disconnected');
},
});
}
(async () => {
await sleep(450);
await typeCommand('music --boot --shuffle');
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
if (trackCount > 0) {
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
} else {
logBeforePins('[audio] library manifest empty', 'term-dim');
}
await sleep(180);
logBeforePins('[audio] queue: shuffle on', 'term-out');
await sleep(180);
logBeforePins('[audio] standby for transmission ✓', 'term-out');
await sleep(280);
startMusic();
})();
</script>
@endsection

View File

@@ -0,0 +1,186 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Box cover</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
html, body {
background: var(--term-bg);
color: var(--term-fg);
font-family: var(--mono);
}
body {
position: relative;
display: flex;
flex-direction: column;
border: 1.5px solid var(--frame);
box-shadow:
0 0 18px var(--frame-glow),
inset 0 0 50px rgba(0, 30, 0, 0.55);
text-shadow: 0 0 4px var(--term-glow);
}
.corner {
position: absolute;
width: 14px; height: 14px;
border-color: var(--frame);
border-style: solid;
border-width: 0;
z-index: 4;
}
.corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; }
.corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; }
.corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; }
.corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 22px 10px;
font-size: 13px; letter-spacing: 0.22em;
color: var(--hud);
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
flex: 0 0 auto;
}
.header .left { display: inline-flex; gap: 10px; align-items: center; }
.header .right { color: var(--hud-dim); font-size: 13px; letter-spacing: 0.12em; }
.art-stage {
position: relative; flex: 1 1 auto; min-height: 0; overflow: hidden;
background: #02080a;
}
#cover {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: contain;
opacity: 0;
transition: opacity 320ms ease;
}
#cover.on { opacity: 1; }
.placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 14px; text-align: center; padding: 0 22px;
color: var(--term-fg-dim);
transition: opacity 320ms ease;
}
.placeholder.off { opacity: 0; }
.placeholder svg { opacity: 0.55; }
.placeholder .ph-text { font-size: 13px; letter-spacing: 0.42em; color: rgba(95, 220, 98, 0.65); }
.placeholder .ph-sub { font-size: 11px; letter-spacing: 0.18em; color: var(--term-fg-dim); }
.scanlines { opacity: 0.32; z-index: 3; }
.caption {
flex: 0 0 auto;
padding: 10px 22px 12px;
font-size: 14px;
border-top: 1px solid rgba(95, 220, 98, 0.18);
color: var(--term-fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
display: flex; gap: 10px; align-items: baseline;
}
.caption .arrow {
color: var(--term-fg-bright);
flex: 0 0 auto;
animation: np-pulse 1.05s ease-in-out infinite alternate;
}
.caption .text {
color: var(--term-fg-bright);
flex: 1 1 auto;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
min-width: 0;
}
.caption.empty .arrow,
.caption.empty .text { color: var(--term-fg-dim); }
body.no-bars .header,
body.no-bars .caption { display: none; }
</style>
</head>
<body>
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="header">
<span class="left">ALBUM ART</span>
<span class="right">CH 118.0 // MUSIC</span>
</div>
<div class="art-stage">
<img id="cover" alt="">
<div class="placeholder" id="placeholder">
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="6" width="52" height="52" rx="2" stroke="#5fdc62" stroke-width="2"/>
<circle cx="32" cy="32" r="14" stroke="#5fdc62" stroke-width="2"/>
<circle cx="32" cy="32" r="3" fill="#5fdc62"/>
</svg>
<span class="ph-text"> NO ART </span>
<span class="ph-sub">awaiting cover…</span>
</div>
<div class="scanlines"></div>
</div>
<div class="caption empty" id="caption">
<span class="arrow"></span><span class="text" id="captionText">connecting…</span>
</div>
@include('partials.obs-ws-scripts')
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
<script>
'use strict';
const params = new URLSearchParams(location.search);
if (params.get('bars') === '0') document.body.classList.add('no-bars');
const coverImg = document.getElementById('cover');
const placeholder = document.getElementById('placeholder');
const caption = document.getElementById('caption');
const captionText = document.getElementById('captionText');
// Cover served by Laravel — same origin as the page, so a relative
// /cover.jpg works without CORS concerns. Cache-busted with ?v=<hash>.
const COVER_URL = '/cover.jpg';
let lastHash = undefined;
function showArt(_path, hash) {
if (hash === lastHash) return;
lastHash = hash;
if (!hash) {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
return;
}
coverImg.onload = () => {
coverImg.classList.add('on');
placeholder.classList.add('off');
};
coverImg.onerror = () => {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
};
coverImg.src = `${COVER_URL}?v=${hash}`;
}
function applyState(s) {
showArt(s.coverPath, s.coverHash);
if (s.title) {
captionText.textContent = s.title;
caption.classList.remove('empty');
} else {
captionText.textContent = '—';
caption.classList.add('empty');
}
}
function setOffline(msg) {
captionText.textContent = msg;
caption.classList.add('empty');
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
lastHash = undefined;
}
OBSWSBootstrap.onState(applyState, {
onClose: () => setOffline('disconnected'),
});
</script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<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; }
#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; }
#status .dim { color: #2c8d2f; }
</style>
</head>
<body>
<div id="status">
<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">now</span><span class="v" id="s-now"></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>
@include('partials.obs-ws-scripts')
<script>
'use strict';
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 setWs(text, cls = '') { $ws.textContent = text; $ws.className = 'v ' + cls; }
let obs = null;
async function connectOBS() {
if (!window.__OBSWS) {
setWs('public/js/obs-config.js MISSING — run `php artisan rig:setup`', '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: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();
</script>
</body>
</html>

View File

@@ -0,0 +1,522 @@
@extends('layouts.overlay')
@section('title', 'NC-MUSIC-BOX')
@section('styles')
<style>
body.overlay-body {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 22px;
font-family: var(--mono);
}
.tui {
display: grid;
grid-template-columns: 1fr 36ch;
gap: 24px;
min-height: 0;
}
.pane {
position: relative;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 4px;
display: flex;
flex-direction: column;
min-height: 0;
box-shadow:
0 0 60px rgba(80, 220, 100, 0.10),
inset 0 0 90px rgba(0, 30, 0, 0.65);
}
.pane::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;
}
.pane > .pane-tab {
position: absolute;
top: -14px; left: 28px;
padding: 2px 14px;
background: var(--bg);
color: var(--term-fg-bright);
font-size: 16px;
letter-spacing: 0.32em;
text-shadow: 0 0 6px var(--term-glow);
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;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
display: grid;
grid-template-rows: auto auto auto auto 1fr auto auto;
gap: 18px;
}
.np-art {
position: relative;
aspect-ratio: 1 / 1;
background: rgba(0, 30, 0, 0.45);
border: 1px solid var(--term-edge-soft);
overflow: hidden;
}
.np-art img {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 320ms ease;
}
.np-art img.on { opacity: 1; }
.np-art .placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 10px; color: var(--term-fg-dim); text-align: center;
transition: opacity 320ms ease;
}
.np-art .placeholder.off { opacity: 0; }
.np-art .placeholder svg { opacity: 0.5; }
.np-art .placeholder .ph-text {
font-size: 14px;
letter-spacing: 0.42em;
color: rgba(95, 220, 98, 0.65);
}
.np-meta {
display: grid; grid-template-columns: 6.5em 1fr;
row-gap: 6px; column-gap: 12px;
font-size: 17px; align-content: start;
}
.np-meta .key { color: var(--term-fg-dim); letter-spacing: 0.18em; }
.np-meta .val {
color: var(--term-fg-bright);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
min-width: 0;
}
.np-meta .val.empty { color: var(--term-fg-dim); }
.np-stats {
display: flex; flex-direction: column; gap: 8px;
padding-top: 6px;
border-top: 1px dashed var(--term-edge-soft);
}
.np-stats .stats-label {
font-size: 14px; letter-spacing: 0.32em;
color: var(--term-fg-dim);
text-shadow: 0 0 4px var(--term-glow);
}
.np-stats .stats-grid {
display: grid; grid-template-columns: 6.5em 1fr;
row-gap: 4px; column-gap: 12px;
font-size: 16px;
}
.np-stats .stats-grid .key { color: var(--term-fg-dim); letter-spacing: 0.16em; }
.np-stats .stats-grid .val {
color: var(--term-fg);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
.np-stats .stats-grid .val.lossless {
color: var(--term-fg-bright);
text-shadow: 0 0 8px var(--term-glow);
}
.np-spectrum { min-height: 64px; }
.np-progress { display: grid; grid-template-rows: auto auto; gap: 6px; }
.np-progress .bar {
position: relative; height: 6px;
background: rgba(95, 220, 98, 0.14);
border: 1px solid var(--term-edge-soft);
}
.np-progress .bar .fill {
position: absolute; inset: 0 auto 0 0;
width: 0%;
background: var(--term-fg);
box-shadow: 0 0 8px var(--term-glow);
transition: width 350ms linear;
}
.np-progress .time {
font-size: 15px; color: var(--term-fg-dim);
letter-spacing: 0.10em;
display: flex; justify-content: space-between;
}
.np-progress .time .now { color: var(--term-fg-bright); }
</style>
@endsection
@push('data-scripts')
<script>window.__TRACK_COUNT = @json($trackCount);</script>
<script src="{{ asset('data/playlist.js') }}"></script>
@endpush
@section('content')
@include('partials.hud-strip', [
'variant' => 'onair',
'label' => 'ON AIR',
'idText' => 'OPHI-118 // MUSIC',
'signalGlyph' => '▮▮▮▮▯',
'signalProfile' => 'strong',
])
<main class="tui">
<section class="pane terminal">
<span class="pane-tab"> TERMINAL </span>
<div id="termOutput"><div id="pinnedBlock"><div class="pin-spacer"></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">next:</span><span class="next-title empty" data-slot="0"> end of queue </span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="1"></span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="2"></span></div><div class="pin-spacer"></div><div class="term-line np-line"><span class="np-arrow"></span><span class="np-title">connecting to daemon…</span><span class="np-time">[--:-- / --:--]</span></div></div></div>
</section>
<aside class="pane np-pane">
<span class="pane-tab"> NOW PLAYING </span>
<div class="np-art">
<img id="cover" alt="">
<div class="placeholder" id="placeholder">
<svg width="56" height="56" viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="5" y="5" width="46" height="46" rx="2" stroke="#5fdc62" stroke-width="2"/>
<circle cx="28" cy="28" r="12" stroke="#5fdc62" stroke-width="2"/>
<circle cx="28" cy="28" r="3" fill="#5fdc62"/>
</svg>
<span class="ph-text"> NO ART </span>
</div>
</div>
<div class="np-meta">
<span class="key">ARTIST</span><span class="val empty" id="metaArtist"></span>
<span class="key">TITLE</span> <span class="val empty" id="metaTitle"></span>
<span class="key">ALBUM</span> <span class="val empty" id="metaAlbum"></span>
<span class="key">YEAR</span> <span class="val empty" id="metaYear"></span>
<span class="key">GENRE</span> <span class="val empty" id="metaGenre"></span>
</div>
<div class="np-stats">
<div class="stats-label"> FILE </div>
<div class="stats-grid">
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat"></span>
<span class="key">QUALITY</span><span class="val empty" id="fileQuality"></span>
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate"></span>
</div>
</div>
<div class="np-stats">
<div class="stats-label"> LIBRARY </div>
<div class="stats-grid">
<span class="key">ARTISTS</span> <span class="val empty" id="statArtists"></span>
<span class="key">ALBUMS</span> <span class="val empty" id="statAlbums"></span>
<span class="key">SONGS</span> <span class="val empty" id="statSongs"></span>
<span class="key">PLAYTIME</span><span class="val empty" id="statPlaytime"></span>
<span class="key">QUEUE</span> <span class="val empty" id="statQueue"></span>
</div>
</div>
<div aria-hidden="true"></div>
<div class="np-spectrum" id="spectrumSlot"></div>
<div class="np-progress">
<div class="bar"><div class="fill" id="progFill"></div></div>
<div class="time">
<span class="now" id="progElapsed">00:00</span>
<span id="progTotal">00:00</span>
</div>
</div>
</aside>
</main>
<section class="status-strip">
<span class="label">CHAT</span>
<span class="cmd-group">
<span><span class="cmd">!skip</span><span class="desc">next track</span></span>
<span><span class="cmd">!queue</span><span class="desc">peek next 3</span></span>
<span><span class="cmd">!info</span><span class="desc">full metadata</span></span>
</span>
</section>
@endsection
@section('scripts')
<script>
'use strict';
const pad = HUD.pad;
const sleep = ms => new Promise(r => setTimeout(r, ms));
const fmtClock = s => (!isFinite(s) || s < 0)
? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`;
const playlistData = window.__PLAYLIST || { tracks: [] };
const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0);
const term = document.getElementById('termOutput');
const pinnedBlock = document.getElementById('pinnedBlock');
const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const nextRows = nextSlots.map(slot => slot.closest('.next-line'));
const PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200;
const MODE_KEYS = ['repeat', 'random', 'single'];
let bootStarted = false;
let bootFinished = false;
let lastPlaybackModes = null;
let pendingState = null;
function pruneTerminal() {
while (term.children.length > MAX_TERM_LINES) {
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
}
function logBeforePins(text, cls = 'term-out', prompt = '> ') {
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.insertBefore(div, pinnedBlock);
pruneTerminal();
}
async function typeCommand(text) {
const div = document.createElement('div');
div.className = 'term-line';
const p = document.createElement('span');
p.className = 'term-prompt';
p.textContent = PROMPT;
div.appendChild(p);
const body = document.createElement('span');
body.className = 'term-out';
div.appendChild(body);
term.insertBefore(div, pinnedBlock);
for (const c of text) {
body.textContent += c;
await sleep(38);
}
await sleep(280);
return body;
}
function normalizePlayback(playback) {
const src = playback && typeof playback === 'object' ? playback : {};
const normalized = {};
for (const key of MODE_KEYS) normalized[key] = !!src[key];
normalized.consume = !!src.consume;
normalized.volume = Number.isFinite(src.volume) ? src.volume : null;
return normalized;
}
function formatBootCommand(playback) {
const p = normalizePlayback(playback);
const parts = ['music', '--boot'];
if (p.volume !== null) parts.push(`--volume=${p.volume}%`);
MODE_KEYS.forEach(key => { if (p[key]) parts.push(`--${key}`); });
if (p.consume) parts.push('--consume');
return parts.join(' ');
}
function nextPreviewConfig(playback) {
const p = normalizePlayback(playback);
if (p.single && p.repeat) return { count: 1, empty: '— repeat single armed —' };
if (p.single) return { count: 0, empty: '— single mode —' };
if (p.random) return { count: 1, empty: '— shuffle active —' };
return { count: 3, empty: '— end of queue —' };
}
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
}
let modeLogChain = Promise.resolve();
function trackModeChanges(playback) {
const current = normalizePlayback(playback);
if (!lastPlaybackModes) { lastPlaybackModes = current; return; }
MODE_KEYS.forEach(key => {
if (current[key] !== lastPlaybackModes[key]) {
modeLogChain = modeLogChain.then(() => logModeChange(key, current[key]));
}
});
lastPlaybackModes = current;
}
const coverImg = document.getElementById('cover');
const placeholder = document.getElementById('placeholder');
const metaArtist = document.getElementById('metaArtist');
const metaTitle = document.getElementById('metaTitle');
const metaAlbum = document.getElementById('metaAlbum');
const metaYear = document.getElementById('metaYear');
const metaGenre = document.getElementById('metaGenre');
const progFill = document.getElementById('progFill');
const progElapsed = document.getElementById('progElapsed');
const progTotal = document.getElementById('progTotal');
const statArtists = document.getElementById('statArtists');
const statAlbums = document.getElementById('statAlbums');
const statSongs = document.getElementById('statSongs');
const statPlaytime = document.getElementById('statPlaytime');
const statQueue = document.getElementById('statQueue');
const fileFormat = document.getElementById('fileFormat');
const fileQuality = document.getElementById('fileQuality');
const fileBitrate = document.getElementById('fileBitrate');
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
function fmtSampleRate(hz) {
if (!hz || !isFinite(hz) || hz <= 0) return null;
const khz = hz / 1000;
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
return `${s} kHz`;
}
function fmtQuality(audio) {
const sr = fmtSampleRate(audio.samplerate);
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
? `${audio.bits}-bit` : null;
if (sr && bits) return `${sr} · ${bits}`;
return sr || bits || null;
}
const fmtCount = n => (typeof n === 'number' && isFinite(n))
? n.toLocaleString('en-US') : null;
function setMeta(el, value) {
if (value && String(value).trim()) {
el.textContent = value;
el.classList.remove('empty');
} else {
el.textContent = '—';
el.classList.add('empty');
}
}
let lastCoverHash = undefined;
function showArt(_path, hash) {
if (hash === lastCoverHash) return;
lastCoverHash = hash;
if (!hash) {
coverImg.classList.remove('on');
placeholder.classList.remove('off');
coverImg.removeAttribute('src');
return;
}
coverImg.onload = () => { coverImg.classList.add('on'); placeholder.classList.add('off'); };
coverImg.onerror = () => { coverImg.classList.remove('on'); placeholder.classList.remove('off'); };
coverImg.src = `/cover.jpg?v=${hash}`;
}
let lastTrackFile = null;
function renderState(s) {
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile && s.title) {
logBeforePins(`[audio] now: ${s.title}`);
}
if (s.file) lastTrackFile = s.file;
npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
const preview = nextPreviewConfig(s.playback);
nextSlots.forEach((slot, i) => {
const v = i < preview.count ? titles[i] : null;
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
nextRows[i].style.display = '';
} else {
if (i === 0) {
slot.textContent = preview.empty;
slot.classList.add('empty');
nextRows[i].style.display = '';
} else {
slot.textContent = '';
slot.classList.add('empty');
nextRows[i].style.display = 'none';
}
}
});
showArt(s.coverPath, s.coverHash);
setMeta(metaArtist, s.artist);
setMeta(metaTitle, s.trackTitle);
setMeta(metaAlbum, s.album);
setMeta(metaYear, s.year);
setMeta(metaGenre, s.genre);
const dur = Number(s.duration) || 0;
const cur = Number(s.currentTime) || 0;
const pct = dur > 0 ? Math.max(0, Math.min(100, (cur / dur) * 100)) : 0;
progFill.style.width = `${pct}%`;
progElapsed.textContent = fmtClock(cur);
progTotal.textContent = fmtClock(dur);
const audio = s.audio || {};
setMeta(fileFormat, audio.format);
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
setMeta(fileQuality, fmtQuality(audio));
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
const stats = s.stats;
if (stats) {
setMeta(statArtists, fmtCount(stats.artists));
setMeta(statAlbums, fmtCount(stats.albums));
setMeta(statSongs, fmtCount(stats.songs));
setMeta(statPlaytime, stats.dbPlaytime);
}
const total = Number(s.total) || 0;
const idx = Number.isInteger(s.index) ? s.index : -1;
setMeta(statQueue, total > 0 && idx >= 0 ? `${idx + 1} / ${total}` : null);
pruneTerminal();
}
async function bootFromState(s) {
if (bootStarted) return;
bootStarted = true;
lastPlaybackModes = normalizePlayback(s.playback);
await typeCommand(formatBootCommand(s.playback));
logBeforePins('[audio] subscribing to mpd:state events', 'term-out');
await sleep(220);
if (trackCount > 0) {
logBeforePins(`[audio] library: ${trackCount} tracks`, 'term-out');
} else {
logBeforePins('[audio] library manifest empty', 'term-dim');
}
await sleep(180);
logBeforePins('[audio] standby for transmission ✓', 'term-out');
bootFinished = true;
if (pendingState) renderState(pendingState);
}
function applyState(s) {
pendingState = s;
if (!bootStarted) { bootFromState(s); return; }
if (!bootFinished) return;
renderState(s);
trackModeChanges(s.playback);
}
function setOffline(msg) {
npTitle.textContent = msg;
npTime.textContent = '[--:-- / --:--]';
}
(async () => {
await sleep(450);
logBeforePins('[audio] waiting for mpd state', 'term-dim', null);
await sleep(280);
OBSWSBootstrap.onState(applyState, {
onClose: () => {
logBeforePins('[audio] daemon connection lost — retrying', 'term-dim');
setOffline('daemon disconnected');
},
});
})();
</script>
@endsection

View File

@@ -0,0 +1,196 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OPHI-118 / Music Box widget</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
/* Widget is authored at 549×880 (bounds_type 0, no stretch). */
html, body {
background: var(--term-bg);
color: var(--term-fg);
font-family: var(--mono);
}
body {
position: relative;
display: flex;
flex-direction: column;
border: 1.5px solid var(--frame);
box-shadow:
0 0 18px var(--frame-glow),
inset 0 0 50px rgba(0, 30, 0, 0.55);
text-shadow: 0 0 4px var(--term-glow);
}
.corner {
position: absolute;
width: 14px; height: 14px;
border-color: var(--frame);
border-style: solid;
border-width: 0;
}
.corner.tl { top: 4px; left: 4px; border-top-width: 2px; border-left-width: 2px; }
.corner.tr { top: 4px; right: 4px; border-top-width: 2px; border-right-width: 2px; }
.corner.bl { bottom: 4px; left: 4px; border-bottom-width: 2px; border-left-width: 2px; }
.corner.br { bottom: 4px; right: 4px; border-bottom-width: 2px; border-right-width: 2px; }
.header {
display: flex; justify-content: space-between; align-items: center;
padding: 14px 22px 10px;
font-size: 13px; letter-spacing: 0.22em;
color: var(--hud);
border-bottom: 1px solid rgba(79, 210, 255, 0.22);
flex: 0 0 auto;
}
.header .left { display: inline-flex; gap: 10px; align-items: center; }
.header .right { color: var(--hud-dim); font-size: 13px; letter-spacing: 0.12em; }
.terminal {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 22px 16px;
font-size: 16px;
line-height: 1.5;
color: var(--term-fg);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.terminal::after { display: none; }
.scanlines { opacity: 0.45; }
.np-line { display: flex; gap: 10px; align-items: baseline; }
.np-line .np-arrow { flex: 0 0 auto; margin-right: 0; }
.np-line .np-title {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.np-line .np-time { flex: 0 0 auto; font-size: 14px; margin-left: 0; }
.next-line { display: flex; gap: 8px; align-items: baseline; font-size: 14px; }
.next-line .next-arrow { flex: 0 0 auto; }
.next-line .next-label { flex: 0 0 auto; }
.next-line .next-title {
flex: 1 1 auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.chat-line {
font-size: 13px;
color: var(--term-fg-dim);
letter-spacing: 0.06em;
border-top: 1px solid rgba(95, 220, 98, 0.18);
padding-top: 8px;
margin-top: 6px;
}
.chat-line .cmd { color: var(--term-fg-bright); }
.pin-spacer { height: 10px; }
</style>
</head>
<body>
<span class="corner tl"></span>
<span class="corner tr"></span>
<span class="corner bl"></span>
<span class="corner br"></span>
<div class="header">
<span class="left">NOW PLAYING</span>
<span class="right">CH 118.0 // MUSIC</span>
</div>
<div class="terminal">
<div id="termOutput"><div id="pinnedBlock"><div class="pin-spacer"></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">next:</span><span class="next-title empty" data-slot="0"> end of queue </span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="1"></span></div><div class="term-line next-line"><span class="next-arrow"></span><span class="next-label">then:</span><span class="next-title empty" data-slot="2"></span></div><div class="pin-spacer"></div><div class="term-line np-line"><span class="np-arrow"></span><span class="np-title">connecting…</span><span class="np-time">[--:-- / --:--]</span></div><div class="term-line chat-line">chat: <span class="cmd">!skip</span> · <span class="cmd">!queue</span> · <span class="cmd">!info</span></div></div></div>
</div>
<div class="scanlines"></div>
@include('partials.obs-ws-scripts')
<script src="{{ asset('js/obs-ws-bootstrap.js') }}"></script>
<script>
'use strict';
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))}`;
const term = document.getElementById('termOutput');
const pinnedBlock = document.getElementById('pinnedBlock');
const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const MAX_TERM_LINES = 60;
function pruneTerminal() {
while (term.children.length > MAX_TERM_LINES) {
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
while (term.children.length > 1) {
let total = 0;
for (const c of term.children) total += c.getBoundingClientRect().height;
if (total <= term.clientHeight) break;
const first = term.firstElementChild;
if (!first || first === pinnedBlock) break;
term.removeChild(first);
}
}
function logBeforePins(text, cls = 'term-out', prompt = '> ') {
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.insertBefore(div, pinnedBlock);
pruneTerminal();
}
let lastTrackFile = null;
function applyState(s) {
if (lastTrackFile !== null && s.file && s.file !== lastTrackFile && s.title) {
logBeforePins(`[audio] now: ${s.title}`);
}
if (s.file) lastTrackFile = s.file;
npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
nextSlots.forEach((slot, i) => {
const v = titles[i];
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
} else {
slot.textContent = i === 0 ? '— end of queue —' : '—';
slot.classList.add('empty');
}
});
pruneTerminal();
}
function setOffline(msg) {
npTitle.textContent = msg;
npTime.textContent = '[--:-- / --:--]';
}
(async () => {
await new Promise(r => setTimeout(r, 200));
logBeforePins('[audio] subscribing to mpd:state', 'term-out');
await new Promise(r => setTimeout(r, 180));
logBeforePins('[audio] queue: shuffle on', 'term-out');
await new Promise(r => setTimeout(r, 180));
logBeforePins('[audio] standby ✓', 'term-out');
await new Promise(r => setTimeout(r, 220));
OBSWSBootstrap.onState(applyState, {
onClose: () => { logBeforePins('daemon disconnected', 'term-dim'); setOffline('disconnected'); },
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
@php
/** @var string $id */
$id = $id ?? 'cam';
$label = $label ?? 'CAM 01';
$showPlaceholder = $showPlaceholder ?? true;
@endphp
<div class="camera-frame" id="{{ $id }}">
<span class="tick tl"></span>
<span class="tick tr"></span>
<span class="tick bl"></span>
<span class="tick br"></span>
<span class="label">{{ $label }}</span>
@if ($showPlaceholder)
<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>
@endif
</div>

View File

@@ -0,0 +1,4 @@
<canvas id="static"></canvas>
<div class="scanlines"></div>
<div class="vignette"></div>
<div class="flicker"></div>

View File

@@ -0,0 +1,27 @@
@php
/** @var string $variant variant: 'live' | 'onair' | 'offair' */
$variant = $variant ?? 'live';
$label = $label ?? 'TRANSMISSION';
$idText = $idText ?? 'OPHI-118';
$signalGlyph = $signalGlyph ?? '▮▮▮▯▯';
$signalProfile = $signalProfile ?? 'normal';
$extraIdSlot = $extraIdSlot ?? null;
@endphp
<header class="hud hud--{{ $variant }}">
<div class="group">
<span><span class="led"></span>{{ $label }}</span>
@if ($extraIdSlot)
{!! $extraIdSlot !!}
@else
<span>{{ $idText }}</span>
@endif
</div>
<div class="group">
<span class="dim">SIGNAL</span>
<span id="signal" data-profile="{{ $signalProfile }}">{{ $signalGlyph }}</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>

View File

@@ -0,0 +1,2 @@
<script src="{{ asset('js/obs-config.js') }}"></script>
<script src="{{ asset('js/obs-ws-mini.js') }}"></script>

View File

@@ -0,0 +1,11 @@
@php
$id = $id ?? 'scr';
$label = $label ?? 'SCR 01';
@endphp
<div class="screen-frame" id="{{ $id }}">
<span class="tick tl"></span>
<span class="tick tr"></span>
<span class="tick bl"></span>
<span class="tick br"></span>
<span class="label">{{ $label }}</span>
</div>

36
webapp/routes/web.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
use App\Http\Controllers\AudioController;
use App\Http\Controllers\DataController;
use App\Http\Controllers\MusicCommandController;
use App\Http\Controllers\Overlays\DesktopController;
use App\Http\Controllers\Overlays\GameController;
use App\Http\Controllers\Overlays\GoodbyeController;
use App\Http\Controllers\Overlays\LandingController;
use App\Http\Controllers\Overlays\LoadingController;
use App\Http\Controllers\Overlays\MusicBoxController;
use App\Http\Controllers\Overlays\MusicDaemonController;
use Illuminate\Support\Facades\Route;
Route::view('/', 'index');
Route::get('/landing', [LandingController::class, 'show']);
Route::get('/loading', [LoadingController::class, 'show']);
Route::get('/game', [GameController::class, 'show']);
Route::get('/desktop', [DesktopController::class, 'show']);
Route::get('/goodbye', [GoodbyeController::class, 'show']);
Route::get('/music-daemon', [MusicDaemonController::class, 'show']);
Route::prefix('music-box')->controller(MusicBoxController::class)->group(function () {
Route::get('/', 'box');
Route::get('widget', 'widget');
Route::get('cover', 'cover');
Route::get('nc', 'nc');
});
Route::get('/data/playlist.js', [DataController::class, 'playlist']);
Route::get('/cover.jpg', [DataController::class, 'coverImage']);
Route::get('/track', [AudioController::class, 'stream']);
Route::post('/cmd/{type}', [MusicCommandController::class, 'send'])
->whereIn('type', ['skip', 'prev', 'pause', 'resume']);

4
webapp/storage/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*
!private/
!public/
!.gitignore

2
webapp/storage/app/private/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

2
webapp/storage/app/public/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

Some files were not shown because too many files have changed in this diff Show More