Compare commits

...

7 Commits

Author SHA1 Message Date
Jakub Zych
c69eb19af3 deploy: default MPD music_directory to ~/Music
Midgolem was the last fresh rig to set up and the music library lives
in ~/Music/Kolekcja, so ~/HDD/Music — the path inherited from Ignia's
secondary-disk layout — no longer reflects the common case. Three
references flipped: the ASSET_DIRS ensure list, the mpd.conf heredoc,
and the post-install empty-library check + warn message.
2026-05-22 21:07:30 +02:00
Jakub Zych
fd1c78b333 webapp: drop rig:playlist pipeline, source track count from mpc stats
The four overlays that read /data/playlist.js (loading, goodbye,
music-box, music/nc) only ever consumed `tracks.length` for cosmetic
"LIBRARY :: N TRACKS" / "indexed N tracks" flavor text — all live
playback signal flows through bridges/mpd-state.py over OBS WS. The
142 KB JSON, ffprobe walk, and ETag-cached route were all producing
one integer that MPD itself already knows.

RigData::playlistCount() now shells `mpc stats` and parses "Songs: N",
returning 0 on any failure (treated by callers as empty library).
Removes RigPlaylistCommand, DataController::playlist(), the
/data/playlist.js route, the per-overlay <script src> + window.__PLAYLIST
shims, and the stale storage/data/playlist.json artifact.

AudioController still consumes config('rig.music_dirs') for the HTTP
audio stream — that's orthogonal and stays.
2026-05-22 21:04:24 +02:00
Jakub Zych
dd3493921b Styling fixes and Olden Era mmodule 2026-05-21 15:00:12 +02:00
Jakub Zych
4ceff4015a webapp: add /onboard wizard — web equivalent of php artisan rig:loading
Browser-based Project Loading manifest builder at http://127.0.0.1:1118/onboard.
Reuses the existing terminal/CRT styling (hud.css palette + scanlines +
phosphor glow) so it doesn't feel like a separate UI:

 - GET  /onboard           — the wizard, seeded from current loading.json
 - GET  /onboard/search?q= — type-ahead Twitch search/categories proxy
 - POST /onboard           — writes storage/data/loading.json, optionally
                              pushes the title + category via TwitchHelix

UX: prompt-line inputs, ↑/↓/Enter on the search dropdown, stepper for the
countdown, ENABLED/DISABLED toggles for camera + microphone, a live
right-pane preview that renders the same lines the loading overlay's
terminal will type. CSRF-exempt POST so the page can submit JSON without
a token round-trip.

Falls back gracefully when TWITCH_CLIENT_ID / TWITCH_TOKEN are absent —
the search is disabled and the manifest still saves locally.
2026-05-21 13:49:19 +02:00
Jakub Zych
b47f8fca83 README: add human-facing root README
CLAUDE.md is the agent-facing repo guide; this is the same project
described for a human walking up to the repo cold. Covers the
architecture, the daily-operation commands, the fresh-rig bring-up,
the critical gotchas (OBS scene-JSON-on-exit, pipewire-pulse restart,
the Flatpak detection trap), and pointers into docs/ and archived/.
2026-05-21 13:32:25 +02:00
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
230 changed files with 21124 additions and 84 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,58 @@ 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: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

181
README.md Normal file
View File

@@ -0,0 +1,181 @@
# OPHI-118
OBS Studio rig for the **ophi118** Twitch channel.
This repo is `~/.config/obs-studio` for two physical machines, **Ignia** and
**Midgolem**. It holds the OBS scene collection, profile, plugin config, the
**Laravel webapp** that renders all on-stream overlays, the **MPD-backed audio
stack** with its OBS-WebSocket bridge, and the **Twitch chat bot** that drives
playback from chat.
```
┌──────────────┐ mpd:state ┌──────────────────────────┐
│ MPD │ ──────────────► │ bridges/mpd-state.py │
│ (PipeWire │ │ (systemd --user) │
│ null-sink) │ ◄────────────── │ │
└──────────────┘ mpc next └──────────────┬───────────┘
▲ │ OBS WS CustomEvent
│ ▼
│ ┌──────────────────────┐
┌───────┴───────┐ │ obs-websocket :4455 │
│ twitch-bot.py │◄────►│ (Studio plugin) │
│ (!skip/!info) │ └──────────┬───────────┘
└───────────────┘ │ on(mpd:state)
┌───────────────────────────┐
│ webapp/ (Laravel) │
│ http://127.0.0.1:1118/ │
│ ┌─────────────────────┐ │
│ │ /landing /loading │ │
│ │ /game /desktop │ │
│ │ /goodbye │ │
│ │ /music-box[/...] │ │
│ │ /music-daemon │ │
│ │ POST /cmd/{type} │ │
│ └─────────────────────┘ │
└───────────────────────────┘
│ browser_source
┌────────┴────────┐
│ OBS Studio │
│ (Flatpak) │
└─────────────────┘
```
## Stack
- **OBS Studio** (Flatpak, `com.obsproject.Studio`) with `filesystems=host`
permission so `~/.config/obs-studio/` is read directly, not indirected
through the per-app private dir.
- **Laravel 13** webapp under `webapp/`, served by `php artisan serve` on
`127.0.0.1:1118` and managed by **supervisord**. Routes return Blade-rendered
overlay pages and a small data API (`/cover.jpg`, `/track?p=…`,
`POST /cmd/{skip|prev|pause|resume}`).
- **MPD** (system service) is the audio player. A small Python bridge
(`bridges/mpd-state.py`, `obs-mpd-bridge.service`) listens to MPD and
re-broadcasts state as `mpd:state` CustomEvents on the OBS WS bus.
- **Twitch chat bot** (`twitch-bot/`, `obs-twitch-bot.service`) handles
`!skip` / `!queue` / `!info` by talking to MPD directly.
## Layout
```
.
├── basic/ OBS scene collection + profile
├── plugin_config/ obs-websocket password (gitignored)
├── plugin_manager/ third-party plugin manifest
├── bridges/ MPD → OBS WS state bridge + cover.jpg cache
├── twitch-bot/ chat bot (git submodule)
├── station/ public rig-spec page (git submodule)
├── webapp/ Laravel app — every overlay served from here
│ ├── app/
│ │ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
│ │ │ rig:cmd
│ │ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,…}, Data,
│ │ │ MusicCommand, Audio
│ │ ├── Services/ ObsWsClient, TwitchHelix, HardwareSnapshot
│ │ └── Support/RigData.php
│ ├── resources/views/ Blade layouts + partials + per-scene overlays
│ ├── public/ css/hud.css, js/{hud,crt-static,obs-ws-*}.js,
│ │ audio/static-hum.wav
│ └── storage/data/ rig:* commands write JSON here
├── scripts/
│ ├── deploy-rig.sh idempotent bring-up (run on fresh rig)
│ ├── obs-webapp.supervisord.conf → /etc/supervisor.d/obs-webapp.conf
│ ├── rewrite-scene-urls.py one-shot migration helper
│ ├── convert.sh, clean.sh yt-dlp .webm → .m4a re-encode tools
├── archived/ pre-webapp artifacts (rollback only)
├── docs/ deeper context — audio, scenes, deployment, history
├── CLAUDE.md agent-facing repo guide (orientation for AI tools)
└── README.md you are here
```
## Daily operation
```bash
# server health
sudo supervisorctl status obs-webapp
tail -F webapp/storage/logs/supervisord.{out,err}.log
# restart after route / controller / .env changes
sudo supervisorctl restart obs-webapp
# blade-view-only changes: no restart needed — just right-click the source
# in OBS → Refresh cache
# refresh a per-session manifest before going live (game / mode / countdown)
cd webapp && php artisan rig:loading
# rescan the music library (after dropping new tracks into ~/Music/Kolekcja)
mpc update --wait
# re-snapshot hardware/OBS profile telemetry (kernel/GPU/encoder changes)
cd webapp && php artisan rig:telemetry --collect
# CLI control of music playback (mirrors the Twitch chat commands)
cd webapp && php artisan rig:cmd skip
# or
curl -X POST http://127.0.0.1:1118/cmd/skip
# rotate the obs-websocket password (or after a fresh rig install)
cd webapp && php artisan rig:setup
sudo supervisorctl restart obs-webapp
```
The OBS scene browser sources point at `http://127.0.0.1:1118/<route>`, so
they always pick up the current state of the webapp on next **Refresh cache**.
## Fresh rig (Midgolem)
```bash
git clone --recurse-submodules <repo> ~/.config/obs-studio
cd ~/.config/obs-studio
bash scripts/deploy-rig.sh
```
`deploy-rig.sh` is idempotent. It installs the Arch packages it needs
(`pacman -S`, AUR helper for the Sansation font), seeds PipeWire null sinks
for the MPD and Discord audio paths, writes the systemd user units for the
bridge + bot, installs the supervisord program, runs `composer install` for
the webapp, and calls `php artisan rig:setup` / `rig:telemetry --collect` to
materialize per-rig state. Then fill in the Twitch tokens it leaves you
prompts for and you're done.
Run it again any time after a `git pull` — it'll only act on what's drifted.
## Critical gotchas
- **Close OBS before editing `basic/scenes/*.json`, `global.ini`, or
`user.ini`.** OBS rewrites them on exit and will clobber your edits.
- **Never restart `pipewire-pulse.service` while OBS is running.** OBS does
not auto-rebind `pulse_output_capture` sources when sinks reload; meters
go flat and stream audio cuts. Stop OBS first, or load modules ad-hoc
with `pactl`.
- **OBS is the Flatpak** (`com.obsproject.Studio`), not the pacman `obs`
binary. Detect with `flatpak ps --columns=application | grep -qx
com.obsproject.Studio``pgrep -fa 'flatpak run …'` lies (the wrapper
exits, the sandbox keeps running).
- **Secrets are per-rig**, never copy between rigs: `twitch-bot/.env*`,
`plugin_config/obs-websocket/config.json`, `basic/profiles/*/service.json`,
`webapp/.env`. Everything else is portable.
## Deeper reading
- [docs/audio.md](docs/audio.md) — PipeWire null-sink architecture, MPD config,
the `mpd:state` event payload, systemd user units.
- [docs/scenes-and-overlays.md](docs/scenes-and-overlays.md) — scene JSON
internals, source ids, HUD browser-resolution constraints,
camera-frame OBS WS sync.
- [docs/deployment.md](docs/deployment.md) — Twitch identity + token scopes,
per-rig vs portable state, Flatpak permission story.
- [docs/history.md](docs/history.md) — migration history: linuxbrowser → CEF,
browser-daemon → MPD bridge, Discord audio split, file:// → HTTP webapp.
- [docs/cheatsheet.md](docs/cheatsheet.md) — one-liners.
- [archived/README.md](archived/README.md) — pre-webapp scene HTML / bash
helpers, kept around for rollback.
## License
Personal config; nothing here is a published product. The Blade templates,
CSS, and scripts are MIT-style — copy what's useful.

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
@@ -218,7 +220,7 @@ fi
ASSET_DIRS=( ASSET_DIRS=(
"$HOME/Videos" "$HOME/Videos"
"$HOME/HDD/Music" "$HOME/Music"
"$HOME/HDD/Images/Gifs" "$HOME/HDD/Images/Gifs"
"$HOME/cloud.jakubzych.com/_img/logos" "$HOME/cloud.jakubzych.com/_img/logos"
) )
@@ -370,7 +372,7 @@ write_mpd_conf() {
# ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh. # ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh.
# Re-run the deploy script to regenerate, or hand-edit for local tweaks. # Re-run the deploy script to regenerate, or hand-edit for local tweaks.
music_directory "~/HDD/Music" music_directory "~/Music"
playlist_directory "~/.config/mpd/playlists" playlist_directory "~/.config/mpd/playlists"
db_file "~/.config/mpd/database" db_file "~/.config/mpd/database"
state_file "~/.config/mpd/state" state_file "~/.config/mpd/state"
@@ -419,13 +421,13 @@ else
fi fi
fi fi
if [[ -d "$HOME/HDD/Music" ]] && [[ -n "$(find "$HOME/HDD/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then if [[ -d "$HOME/Music" ]] && [[ -n "$(find "$HOME/Music" -maxdepth 3 -type f -print -quit 2>/dev/null)" ]]; then
if would "mpc update"; then if would "mpc update"; then
mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)" mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)"
ok "MPD library scan kicked off" ok "MPD library scan kicked off"
fi fi
else else
warn "~/HDD/Music is empty — MPD will have no tracks until you populate it" warn "~/Music is empty — MPD will have no tracks until you populate it"
fi fi
# Avahi for zeroconf advertisement (system service). # Avahi for zeroconf advertisement (system service).
@@ -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

166
webapp/README.md Normal file
View File

@@ -0,0 +1,166 @@
# OPHI-118 webapp
Laravel app that renders every on-stream overlay for the OPHI-118 Twitch rig and
hosts the streamer's control surfaces. Runs under supervisord on `http://127.0.0.1:1118/`.
> Looking for the rig-wide picture (OBS, MPD, twitch-bot, deployment)?
> See [`../README.md`](../README.md). This file is just the webapp.
## What's in here
### Control panel — `/`
Tile-based dashboard. Entry point from a browser bookmark; not used by OBS itself.
![Control panel](resources/img/screenshots/home.webp)
- **ONBOARD** — opens the project-loading wizard.
- **SCENES** — opens the full route index.
- **ophi118.com** — public channel landing page.
- **gamez.ciemnosc.com** — self-hosted game-server roster.
### Onboard wizard — `/onboard`
Web equivalent of `php artisan rig:loading`. Compiles the Project Loading manifest
(game, subtitle, countdown, camera/mic state) and optionally pushes the channel
title + category to Twitch via Helix.
![Onboard form](resources/img/screenshots/onboard1.webp)
- **Type-ahead game search** against the Twitch Helix `search/categories`
endpoint, debounced. ↑/↓/Enter/Esc keys to pick a match; locks the `gameId`.
- **Live preview** mirrors what the `/loading` overlay will render — every edit
re-renders the terminal-style output immediately.
- **Stepper** for the countdown, **ENABLED/DISABLED toggles** for camera/mic,
**push-to-Twitch** checkbox to commit the channel update via Helix.
- **Graceful fallback**: with `TWITCH_CLIENT_ID` / `TWITCH_ACCESS_TOKEN` unset,
the search disables and the manifest still saves locally.
![Onboard preview](resources/img/screenshots/onboard2.webp)
### Scenes index — `/scenes`
All overlay URLs the OBS browser-sources point at, plus the data endpoints.
![Scenes](resources/img/screenshots/scenes-page.webp)
### Overlay routes
Browser sources in `basic/scenes/Default_Stream_HUD.json` consume these. Edits
to the Blade views are picked up on the next OBS **Refresh cache** — no server
restart needed.
| Route | Purpose |
|---|---|
| `/landing` | Fallout-style "PLEASE STAND BY" + telemetry rotator |
| `/loading` | Terminal-style Project Loading + countdown |
| `/game` | Game HUD (camera-frame OBS-WS sync, status bar) |
| `/desktop` | Desktop HUD (camera + screen-capture frames) |
| `/goodbye` | Sign-off terminal + "OFFLINE FOR" counter |
| `/music-box` | Full-screen music box terminal |
| `/music-box/widget` | Compact 549×880 widget |
| `/music-box/cover` | Album-art widget (`?bars=0` for compact mode) |
| `/music-box/nc` | ncurses-styled 2-pane variant |
| `/music-daemon` | Passive WebSocket listener (diagnostic) |
### Data + control endpoints
| Route | Notes |
|---|---|
| `GET /cover.jpg` | Streams `bridges/cover.jpg` (kept fresh by `mpd-state.py`) |
| `GET /track?p=<base64>` | Audio stream — path-traversal-guarded |
| `POST /cmd/{skip\|prev\|pause\|resume}` | Broadcasts `mpd:cmd` over OBS WebSocket |
## Artisan commands
Each lives at `app/Console/Commands/Rig*Command.php`. Run from the `webapp/`
directory.
```bash
php artisan rig:setup # gen public/js/obs-config.js + OBS_WS_* in .env
php artisan rig:telemetry # refresh storage/data/telemetry.json
php artisan rig:telemetry --collect # re-prompt every field interactively
php artisan rig:loading # interactive Project Loading manifest (CLI mirror of /onboard)
php artisan rig:cmd skip # CLI mirror of POST /cmd/skip
```
## Running it
The supervisord program file lives at [`../scripts/obs-webapp.supervisord.conf`](../scripts/obs-webapp.supervisord.conf)
and is installed (root-owned) to `/etc/supervisor.d/obs-webapp.conf` by
`scripts/deploy-rig.sh`.
```bash
sudo supervisorctl status obs-webapp # → RUNNING
sudo supervisorctl restart obs-webapp # after route / controller / .env changes
tail -F storage/logs/supervisord.{out,err}.log
```
For local hacking without supervisord:
```bash
php artisan serve --host=127.0.0.1 --port=1118
```
## When to restart
| Change | Action |
|---|---|
| Blade view (`resources/views/`) | OBS → right-click source → **Refresh cache** |
| Static CSS / JS (`public/`) | Same — Refresh cache |
| Route / controller / service / config / `.env` | `sudo supervisorctl restart obs-webapp` |
## Layout
```
webapp/
├── app/
│ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
│ │ rig:cmd
│ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,Desktop,
│ │ Goodbye,MusicBox,MusicDaemon}, Data,
│ │ MusicCommand, Audio, Onboard
│ ├── Services/ ObsWsClient (Pawl), TwitchHelix,
│ │ HardwareSnapshot
│ └── Support/RigData.php
├── resources/
│ ├── views/
│ │ ├── home.blade.php /
│ │ ├── scenes.blade.php /scenes
│ │ ├── onboard.blade.php /onboard
│ │ ├── overlays/ per-scene overlays
│ │ ├── partials/ topbar, hud-strip, crt-overlays, …
│ │ └── layouts/ overlay base layout
│ └── img/ favicon + screenshots (this README)
├── public/
│ ├── css/hud.css shared HUD chrome, scanlines, terminal styling
│ ├── js/ hud, crt-static, obs-ws-bootstrap, obs-ws-mini
│ ├── audio/static-hum.wav ffmpeg_source (not browser-served)
│ └── favicon.ico → symlink to ../resources/img/favicon.ico
├── routes/web.php
├── config/rig.php storage paths, OBS WS creds, Twitch keys
└── storage/data/ rig:* commands write JSON here
├── loading.json
└── telemetry.json
```
## Config
`.env` keys the webapp cares about:
```
APP_URL=http://127.0.0.1:1118
OBS_WS_URL=ws://localhost:4455
OBS_WS_PASSWORD=… # written by `php artisan rig:setup`
TWITCH_CLIENT_ID=…
TWITCH_ACCESS_TOKEN=… # legacy TWITCH_TOKEN is also accepted
TWITCH_BROADCASTER_ID=…
RIG_NAME=…
OBS_PROFILE=ophi118
MUSIC_DIR_1=…
```
See [`.env.example`](.env.example) for the full set.

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,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,35 @@
<?php
namespace App\Http\Controllers;
use App\Support\RigData;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
class DataController extends Controller
{
/**
* 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,159 @@
<?php
namespace App\Http\Controllers;
use App\Services\TwitchHelix;
use App\Support\OldenEra;
use App\Support\RigData;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
/**
* Web-based equivalent of `php artisan rig:loading` guides the streamer
* through the Project Loading manifest fields and pushes the channel
* update to Twitch via Helix.
*
* Lives at /onboard so it's reachable from the same server as the
* overlays (bookmark http://127.0.0.1:1118/onboard).
*/
class OnboardController extends Controller
{
public function show(RigData $rig, TwitchHelix $twitch, OldenEra $olden)
{
return view('onboard', [
'manifest' => $rig->loading(),
'telemetry' => $rig->telemetry(),
'trackCount' => $rig->playlistCount(),
'twitchEnabled' => $twitch->enabled(),
'oldenEra' => [
'gameId' => OldenEra::GAME_ID,
'assetBase' => OldenEra::URL,
'catalog' => $olden->catalog(),
'manifest' => $olden->manifest(),
],
]);
}
public function search(Request $request, TwitchHelix $twitch): JsonResponse
{
if (!$twitch->enabled()) {
return response()->json(['error' => 'twitch not configured'], 503);
}
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 2) {
return response()->json(['matches' => []]);
}
try {
// Use the HTTP API directly to get the top-N candidates, not just
// the single best-match the Artisan command uses.
$hits = $this->rawSearch($twitch, $q);
} catch (Throwable $e) {
return response()->json(['error' => $e->getMessage()], 502);
}
return response()->json(['matches' => $hits]);
}
public function submit(Request $request, RigData $rig, TwitchHelix $twitch, OldenEra $olden): JsonResponse
{
$data = $request->validate([
'game' => 'required|string|max:200',
'gameId' => 'nullable|string|max:32',
'subtitle' => 'nullable|string|max:300',
'countdownMin' => 'required|integer|min:0|max:120',
'camera' => 'required|boolean',
'microphone' => 'required|boolean',
'pushTwitch' => 'nullable|boolean',
]);
$manifest = [
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
'game' => $data['game'],
'gameId' => $data['gameId'] ?? null,
'subtitle' => $data['subtitle'] ?: null,
'countdownMin' => (int) $data['countdownMin'],
'camera' => (bool) $data['camera'],
'microphone' => (bool) $data['microphone'],
];
$path = rtrim(config('rig.storage_data'), '/') . '/loading.json';
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
$oldenPath = null;
if (($manifest['gameId'] ?? null) === OldenEra::GAME_ID) {
$factions = array_keys($olden->catalog());
$extra = $request->validate([
'olden.color' => 'required|in:red,blue',
'olden.my.faction' => 'required|string|in:' . implode(',', $factions),
'olden.my.hero' => 'required|string|max:120',
'olden.enemy.faction' => 'required|string|in:' . implode(',', $factions),
'olden.enemy.hero' => 'required|string|max:120',
'olden.map' => 'nullable|string|max:200',
])['olden'];
$oldenPath = $olden->writeManifest([
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
'gameId' => OldenEra::GAME_ID,
'color' => $extra['color'],
'my' => $extra['my'],
'enemy' => $extra['enemy'],
'map' => isset($extra['map']) && $extra['map'] !== '' ? $extra['map'] : null,
]);
}
$twitchStatus = null;
if (!empty($data['pushTwitch']) && $twitch->enabled() && !empty($manifest['gameId'])) {
try {
$title = $manifest['subtitle'] ?: $manifest['game'];
$twitch->setChannel($title, $manifest['gameId']);
$twitchStatus = "ok: \"{$title}\" / {$manifest['game']}";
} catch (Throwable $e) {
$twitchStatus = 'failed: ' . $e->getMessage();
}
}
return response()->json([
'ok' => true,
'manifest' => $manifest,
'twitch' => $twitchStatus,
'path' => str_replace(base_path() . '/', '', $path),
'oldenPath' => $oldenPath ? str_replace(base_path() . '/', '', $oldenPath) : null,
]);
}
/**
* Raw search/categories Helix call same shape as TwitchHelix internally
* but returns multiple candidates instead of the first exact match.
*/
private function rawSearch(TwitchHelix $twitch, string $q): array
{
$reflection = new \ReflectionClass($twitch);
$http = $reflection->getProperty('http')->getValue($twitch);
$headers = $reflection->getMethod('headers');
$headers->setAccessible(true);
$res = $http->get('search/categories', [
'headers' => $headers->invoke($twitch),
'query' => ['query' => $q, 'first' => 10],
]);
$body = json_decode((string) $res->getBody(), true);
$hits = $body['data'] ?? [];
// Sort so case-insensitive exact matches surface first.
usort($hits, function ($a, $b) use ($q) {
$ax = strcasecmp((string) ($a['name'] ?? ''), $q) === 0 ? 0 : 1;
$bx = strcasecmp((string) ($b['name'] ?? ''), $q) === 0 ? 0 : 1;
return $ax <=> $bx;
});
return array_map(fn($r) => [
'id' => (string) ($r['id'] ?? ''),
'name' => (string) ($r['name'] ?? ''),
'boxArt' => (string) ($r['box_art_url'] ?? ''),
], array_slice($hits, 0, 10));
}
}

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,24 @@
<?php
namespace App\Http\Controllers\Overlays;
use App\Http\Controllers\Controller;
use App\Support\OldenEra;
use App\Support\RigData;
class GameController extends Controller
{
public function show(RigData $rig, OldenEra $olden)
{
$manifest = $rig->loading();
$isOlden = ($manifest['gameId'] ?? null) === OldenEra::GAME_ID;
$oldenManifest = $isOlden ? $olden->manifest() : [];
return view('overlays.game', [
'manifest' => $manifest,
'olden' => !empty($oldenManifest) ? $oldenManifest : null,
'oldenAssetBase' => OldenEra::URL,
'rigName' => (string) (config('rig.streamer_nick') ?: 'OPHI118'),
]);
}
}

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,93 @@
<?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_ACCESS_TOKEN (user OAuth token with the
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env.
* Legacy TWITCH_TOKEN is still accepted as a fallback (see config/rig.php).
*/
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,78 @@
<?php
namespace App\Support;
/**
* Per-game extra manifest for Heroes of Might & Magic: Olden Era.
*
* `loading.json` keeps the generic stream metadata (game, subtitle, mic…).
* Olden Era specifics matchup factions, heroes, the player's team color,
* map name live in their own `olden-era-manifest.json` so the loading
* schema doesn't grow a new key every time a game gets bespoke chrome.
*/
class OldenEra
{
/** Twitch Helix category id for "Heroes of Might & Magic: Olden Era". */
public const GAME_ID = '1426873999';
/** Public URL prefix; resolved via the webapp/public/games symlink. */
public const URL = '/games/olden-era';
private ?array $catalogCache = null;
/**
* Faction {logo URL, heroes list}. Built by globbing
* resources/games/olden-era/{factions,heroes/<Faction>}/*.png.
*/
public function catalog(): array
{
if ($this->catalogCache !== null) return $this->catalogCache;
$root = resource_path('games/olden-era');
$out = [];
foreach (glob($root . '/factions/*.png') ?: [] as $factionPng) {
$faction = pathinfo($factionPng, PATHINFO_FILENAME);
$heroes = [];
foreach (glob($root . '/heroes/' . $faction . '/*.png') ?: [] as $heroPng) {
$name = pathinfo($heroPng, PATHINFO_FILENAME);
$heroes[] = [
'name' => $name,
'portrait' => self::URL . '/heroes/' . rawurlencode($faction) . '/' . rawurlencode($name) . '.png',
];
}
usort($heroes, fn($a, $b) => strcasecmp($a['name'], $b['name']));
$out[$faction] = [
'logo' => self::URL . '/factions/' . rawurlencode($faction) . '.png',
'heroes' => $heroes,
];
}
ksort($out);
return $this->catalogCache = $out;
}
public function manifest(): array
{
$path = $this->manifestPath();
if (!is_file($path)) return [];
$parsed = json_decode((string) @file_get_contents($path), true);
return is_array($parsed) ? $parsed : [];
}
public function writeManifest(array $data): string
{
$path = $this->manifestPath();
@mkdir(dirname($path), 0775, true);
file_put_contents(
$path,
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
);
return $path;
}
public function manifestPath(): string
{
return rtrim(config('rig.storage_data'), '/') . '/olden-era-manifest.json';
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Support;
use Symfony\Component\Process\Process;
use Throwable;
class RigData
{
private array $cache = [];
private ?int $mpdSongCount = null;
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' => [],
]);
}
/**
* Track count from MPD (the live library, queried via `mpc stats`).
* Returns 0 if mpc is missing or MPD is unreachable callers treat
* that as "empty library" and degrade their flavor text accordingly.
*/
public function playlistCount(): int
{
if ($this->mpdSongCount !== null) return $this->mpdSongCount;
try {
$proc = new Process(['mpc', 'stats']);
$proc->setTimeout(2);
$proc->run();
if ($proc->isSuccessful() && preg_match('/^Songs:\s*(\d+)/m', $proc->getOutput(), $m)) {
return $this->mpdSongCount = (int) $m[1];
}
} catch (Throwable) {
// fall through
}
return $this->mpdSongCount = 0;
}
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 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);

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

@@ -0,0 +1,20 @@
<?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/*',
'onboard',
]);
})
->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',
],
];

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

@@ -0,0 +1,61 @@
<?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'),
'streamer_nick' => env('STREAMER_NICK', 'OPHI118'),
'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'),
// Name aligned with twitch-bot/.env (TWITCH_ACCESS_TOKEN); old TWITCH_TOKEN
// kept as a fallback so pre-existing webapp/.env files still work.
'token' => env('TWITCH_ACCESS_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>

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

@@ -0,0 +1,689 @@
/* ================================================================
* 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); }
/* Olden Era matchup — right-aligned on the status-bar's LIVE/game-name line. */
.matchup {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 14px;
font-size: 16px;
letter-spacing: 0.16em;
}
.matchup .m-side {
display: inline-flex;
align-items: center;
gap: 10px;
}
.matchup .m-faction {
width: 34px;
height: 34px;
object-fit: contain;
filter: drop-shadow(0 0 6px rgba(7, 8, 13, 0.9));
}
.matchup .m-hero {
width: 38px;
height: 38px;
object-fit: cover;
border-radius: 50%;
border: 1px solid rgba(232, 232, 224, 0.35);
background: rgba(0, 0, 0, 0.4);
}
.matchup .m-name {
color: var(--ink);
font-weight: 700;
letter-spacing: 0.14em;
}
.matchup .m-vs {
color: var(--hud-dim);
font-weight: 700;
letter-spacing: 0.32em;
font-size: 14px;
}
.matchup .m-on {
color: var(--hud-dim);
font-size: 13px;
letter-spacing: 0.32em;
margin-left: 6px;
}
.matchup .m-map {
color: var(--hud);
font-size: 15px;
letter-spacing: 0.18em;
}
.matchup-red .m-my .m-name {
color: #ff6a5e;
text-shadow: 0 0 6px rgba(230, 58, 46, 0.55);
}
.matchup-blue .m-my .m-name {
color: #6ec5ff;
text-shadow: 0 0 6px rgba(58, 166, 255, 0.55);
}
.matchup-red .m-my .m-hero { border-color: rgba(230, 58, 46, 0.7); }
.matchup-blue .m-my .m-hero { border-color: rgba(58, 166, 255, 0.7); }
/*
* Webapp navigation breadcrumb (used by /, /scenes, /onboard).
* Distinct from the in-scene .hud strip so overlays stay untouched.
* */
.crumb-bar {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
font-family: var(--mono);
font-size: 14px;
font-weight: 700;
color: var(--hud);
letter-spacing: 0.10em;
padding-bottom: 14px;
margin-bottom: 28px;
border-bottom: 1px solid rgba(79, 210, 255, 0.25);
}
.crumb-bar > :nth-child(2) { justify-self: center; }
.crumb-bar > :nth-child(3) { justify-self: end; }
.crumbs { display: flex; align-items: center; gap: 12px; }
.crumbs .led {
display: inline-block;
width: 9px; height: 9px;
border-radius: 50%;
background: var(--onair);
box-shadow: 0 0 8px var(--onair);
animation: rec-blink 1.6s ease-in-out infinite;
}
.crumb { color: var(--hud); text-decoration: none; transition: color 120ms ease, text-shadow 120ms ease; }
.crumb-root { color: var(--hud-dim); }
.crumb-root:hover{ color: var(--hud); text-shadow: 0 0 8px var(--hud-glow); }
.crumb-here { color: var(--hud); text-shadow: 0 0 8px var(--hud-glow); cursor: default; }
.crumbs .sep { color: var(--hud-dim); opacity: 0.6; }
.crumb-ctx, .crumb-clock { color: var(--hud); }
.crumb-ctx .dim, .crumb-clock .dim { color: var(--hud-dim); margin-right: 8px; }
/*
* Webapp page shell used by every non-overlay browser page
* (/, /scenes, /onboard) so the crumb-bar and content sit at the
* same viewport anchor on every page.
* Overlays keep .overlay-body / .camera-hud-body for OBS sizing.
* */
body.page-shell {
background: var(--bg);
position: relative;
padding: 32px 56px;
min-height: 100vh;
overflow: auto;
}

1
webapp/public/favicon.ico Symbolic link
View File

@@ -0,0 +1 @@
../resources/img/favicon.ico

1
webapp/public/games Symbolic link
View File

@@ -0,0 +1 @@
../resources/games

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 @@
Files here belongs to Unfrozen and are copies from https://wiki.hoodedhorse.com/Heroes_of_Might_and_Magic_Olden_Era - used only for game streaming purposes.

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

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