Compare commits
7 Commits
c8f9c11c93
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c69eb19af3 | ||
|
|
fd1c78b333 | ||
|
|
dd3493921b | ||
|
|
4ceff4015a | ||
|
|
b47f8fca83 | ||
|
|
0b4f121a92 | ||
|
|
059f069ef4 |
60
.gitignore
vendored
@@ -24,36 +24,47 @@ plugin_config/obs-websocket/
|
||||
# Machine-local generated data
|
||||
# ============================================================
|
||||
|
||||
# Generated wrapper for telemetry.json — built by telemetry.sh, not edited.
|
||||
# (The JSON itself is tracked: it's the hand-edited source for the wrapper.)
|
||||
landing/telemetry.js
|
||||
|
||||
# Per-session manifest written by loading.sh — ephemeral, not version-controlled.
|
||||
loading/loading.json
|
||||
loading/loading.js
|
||||
|
||||
# Playlist manifest — generated from the audio in /playlist/, machine-local.
|
||||
loading/playlist.json
|
||||
loading/playlist.js
|
||||
|
||||
# Music daemon command file — written by playlist.sh skip/prev/pause/resume.
|
||||
loading/cmd.js
|
||||
|
||||
# Audio files for the loading scene (any format). Kept out of git regardless
|
||||
# of how they got there (yt-dlp, manual copy, etc.).
|
||||
/playlist/
|
||||
# Per-session rig data written by `php artisan rig:loading`, `rig:playlist`,
|
||||
# `rig:telemetry`. Re-derived on demand from the live system + Twitch API.
|
||||
webapp/storage/data/*.json
|
||||
|
||||
# OBS WebSocket connection details (port + password) — auto-generated by
|
||||
# setup.sh from your existing plugin_config/obs-websocket/config.json.
|
||||
vendor/obs-config.js
|
||||
# `php artisan rig:setup` from plugin_config/obs-websocket/config.json.
|
||||
webapp/public/js/obs-config.js
|
||||
|
||||
# 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
|
||||
|
||||
# Local backups created during edits — keep out of git noise.
|
||||
landing/*.manual
|
||||
loading/*.manual
|
||||
# Audio files for the loading/music-box scenes (any format). Kept out of git
|
||||
# regardless of how they got there (yt-dlp, manual copy, etc.).
|
||||
/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
|
||||
@@ -99,3 +110,4 @@ __pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
temp
|
||||
.claude/
|
||||
|
||||
62
CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Current state (read this first)
|
||||
|
||||
Active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), six custom HTML/CSS/JS overlays rendered as `browser_source`, an MPD-based audio architecture with a state bridge to OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target).
|
||||
Active OBS Studio config dir for the **ophi118** Twitch streaming rig. It hosts: six scenes (Landing, Project Loading, Game, Desktop, Music Box, Good Bye), a Laravel webapp (`webapp/`) that serves the scene overlays over `http://127.0.0.1:1118/` under supervisord, an MPD-based audio architecture with `bridges/mpd-state.py` re-broadcasting MPD state as `mpd:state` events over OBS WebSocket, a Twitch chat bot that controls MPD via `!skip`/`!queue`/`!info`, and an optional Mattermost notifier that posts when the channel goes live. Two rigs share this codebase via a single git repo: **Ignia** (current) and **Midgolem** (deployment target).
|
||||
|
||||
## What this directory is
|
||||
|
||||
@@ -32,38 +32,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.
|
||||
- `logs/`, `profiler_data/`, `.sentinel/` — runtime state.
|
||||
|
||||
**Custom overlays (browser sources rendering local HTML — safe to edit while OBS is up):**
|
||||
- `landing/` — Fallout-style "PLEASE STAND BY" overlay + telemetry + 12 s static-hum loop.
|
||||
- `loading/` — terminal-style Project Loading overlay + per-session manifest + flat playlist index + legacy `cmd.js`.
|
||||
- `game/index.html` — Game scene HUD (TRANSMISSION ID, signal, clock, camera frame, status bar). Live-syncs camera transform via OBS WS.
|
||||
- `desktop/index.html` — Desktop scene HUD, forked from Game (no game-name slot, no music widget — that lives below the camera as a separate source).
|
||||
- `goodbye/index.html` — sign-off overlay.
|
||||
- `music-box/index.html` — full-screen Music Box terminal (scrolling `[audio]` log + pinned 3-up next + chat help strip).
|
||||
- `music-box/widget.html` — compact 549×880 sidebar widget for the Desktop scene. **Authored at native pixel size** (`bounds_type: 0`, no stretch).
|
||||
- `music/index.html` — legacy "Music Daemon" browser source.
|
||||
- `vendor/obs-ws-mini.js` — minimal OBS WebSocket v5 client. `vendor/obs-config.js` is the auto-generated WS connection config (gitignored).
|
||||
**Scene overlays (`webapp/` — Laravel app served on 127.0.0.1:1118 by supervisord):**
|
||||
- `webapp/resources/views/overlays/landing.blade.php` — Fallout-style "PLEASE STAND BY" overlay + telemetry rotator.
|
||||
- `webapp/resources/views/overlays/loading.blade.php` — terminal-style Project Loading overlay + countdown.
|
||||
- `webapp/resources/views/overlays/game.blade.php` — Game HUD (camera-frame OBS-WS sync, status bar).
|
||||
- `webapp/resources/views/overlays/desktop.blade.php` — Desktop HUD (camera + screen-capture frames).
|
||||
- `webapp/resources/views/overlays/goodbye.blade.php` — sign-off terminal + "OFFLINE FOR" counter.
|
||||
- `webapp/resources/views/overlays/music/box.blade.php` — full-screen Music Box terminal.
|
||||
- `webapp/resources/views/overlays/music/widget.blade.php` — compact 549×880 widget (`bounds_type: 0`, no stretch).
|
||||
- `webapp/resources/views/overlays/music/cover.blade.php` — album-art widget (`?bars=0` for compact mode).
|
||||
- `webapp/resources/views/overlays/music/nc.blade.php` — ncurses-styled 2-pane Music Box.
|
||||
- `webapp/resources/views/overlays/music/daemon.blade.php` — passive diagnostic; playback is handled by MPD + `bridges/mpd-state.py`.
|
||||
- `webapp/public/css/hud.css` — extracted shared HUD chrome, scanlines, terminal styling, pulse keyframes.
|
||||
- `webapp/public/js/{hud,crt-static,obs-ws-bootstrap,obs-ws-mini}.js` — shared JS; `obs-config.js` is gitignored, generated by `php artisan rig:setup`.
|
||||
- Routes + controllers under `webapp/app/Http/Controllers/`. See `routes/web.php` for the URL → handler table.
|
||||
|
||||
**Artisan commands (replace the old bash helpers — see `archived/scripts/`):**
|
||||
- `php artisan rig:setup` — generate `webapp/public/js/obs-config.js` + `OBS_WS_*` env from `plugin_config/obs-websocket/config.json`.
|
||||
- `php artisan rig:telemetry [--collect]` — refresh hardware/OBS-profile snapshot at `webapp/storage/data/telemetry.json`.
|
||||
- `php artisan rig:loading` — interactive manifest builder; updates Twitch channel via Helix (no Python shell-out).
|
||||
- `php artisan rig:cmd <skip|prev|pause|resume>` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`).
|
||||
|
||||
**Audio + automation surfaces:**
|
||||
- `bridges/mpd-state.py` — MPD → OBS WS state bridge (`obs-mpd-bridge.service`).
|
||||
- `twitch-bot/bot.py` — Twitch chat bot for `!skip` / `!queue` / `!info` + optional Mattermost going-live notifier (`obs-twitch-bot.service`).
|
||||
- `twitch-bot/{search-game,set-channel,_twitch}.py` — Helix helpers used by `scripts/loading.sh`.
|
||||
- `bridges/mpd-state.py` — MPD → OBS WS state bridge (`obs-mpd-bridge.service`). Writes `bridges/cover.jpg`. Authoritative source of `mpd:state` events.
|
||||
- `twitch-bot/bot.py` — Twitch chat bot for `!skip` / `!queue` / `!info`. Talks to MPD directly, not via OBS WS.
|
||||
- `twitch-bot/{search-game,set-channel,_twitch}.py` — Helix helpers (still present; `rig:loading` no longer needs them — it uses Guzzle directly).
|
||||
- `twitch-bot/.env*` — secrets (per-rig, never copy).
|
||||
|
||||
**Scripts (`.sh` lives only here):**
|
||||
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig.
|
||||
- `scripts/setup.sh` — generate `vendor/obs-config.js` after a WS password change.
|
||||
- `scripts/telemetry.sh` — refresh `landing/telemetry.json` + wrap to `.js`.
|
||||
- `scripts/loading.sh` — interactive `loading.json` builder; pushes title/category to Twitch.
|
||||
- `scripts/playlist.sh` — playlist sync + legacy daemon control.
|
||||
- `scripts/deploy-rig.sh` — idempotent bring-up for a fresh rig (now also installs the supervisord program + runs `composer install`).
|
||||
- `scripts/obs-webapp.supervisord.conf` — supervisord program file; install to `/etc/supervisor.d/obs-webapp.conf`.
|
||||
- `scripts/rewrite-scene-urls.py` — one-shot tool that rewrote `basic/scenes/Default_Stream_HUD.json` from `file://` to `http://127.0.0.1:1118/...` URLs during migration. Idempotent.
|
||||
- `scripts/convert.sh`, `scripts/clean.sh` — yt-dlp `.webm` → AAC `.m4a` re-encode + audit.
|
||||
|
||||
**Reference / data:** `playlist/` (gitignored library), `profile/` (rig spec docs), `station/` (git submodule for the public rig-spec page).
|
||||
**Reference / archive:** `archived/` (pre-migration scenes + bash scripts + `vendor/`, kept for rollback — see `archived/README.md`). `playlist/` (gitignored audio library). `profile/` (rig spec docs). `station/` (submodule for the public rig-spec page).
|
||||
|
||||
## How OBS reaches the overlays — supervisord + Laravel
|
||||
|
||||
OBS browser sources point at `http://127.0.0.1:1118/<route>` (rewritten from the old `file://...` URLs by `scripts/rewrite-scene-urls.py`). The Laravel app runs under supervisord:
|
||||
|
||||
```
|
||||
sudo supervisorctl status obs-webapp # → RUNNING
|
||||
sudo supervisorctl restart obs-webapp # after route / controller / .env changes
|
||||
tail -F webapp/storage/logs/supervisord.{out,err}.log
|
||||
```
|
||||
|
||||
The program file is `scripts/obs-webapp.supervisord.conf`; the installed copy is at `/etc/supervisor.d/obs-webapp.conf` (root-owned).
|
||||
|
||||
## Critical gotcha: OBS overwrites on exit
|
||||
|
||||
OBS rewrites `basic/scenes/<collection>.json`, `global.ini`, and `user.ini` on shutdown (and periodically). **Close OBS before editing these files**, or the next OBS exit will clobber the edit. After editing, re-launch OBS to load the change.
|
||||
|
||||
**HTML overlays under `landing/`, `loading/`, `game/`, `desktop/`, `goodbye/`, `music-box/`, `music/` are safe to edit while OBS is up** — CEF re-reads them on demand. Apply the change with right-click source → **Refresh cache** in OBS, no restart needed.
|
||||
**Blade views under `webapp/resources/views/overlays/` are safe to edit while OBS is up** — CEF re-fetches on right-click source → **Refresh cache** in OBS, no restart needed. **Controller / route / `.env` changes need a supervisord restart** (`sudo supervisorctl restart obs-webapp`) — only then will the browser sources see the new behavior on next Refresh cache. CSS and JS under `webapp/public/` are pure static files; no restart needed, just Refresh cache.
|
||||
|
||||
**Detecting whether OBS is running** — the Flatpak's inner binary is just `obs`, not `flatpak run com.obsproject.Studio`. Use:
|
||||
```bash
|
||||
|
||||
181
README.md
Normal 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
@@ -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.
|
||||
@@ -269,7 +269,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"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,
|
||||
"height": 1336,
|
||||
"fps": 30,
|
||||
@@ -309,7 +310,7 @@
|
||||
"id": "ffmpeg_source",
|
||||
"versioned_id": "ffmpeg_source",
|
||||
"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,
|
||||
"hw_decode": false,
|
||||
"is_local_file": true,
|
||||
@@ -350,9 +351,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/loading/index.html",
|
||||
"url": "file:///home/jin/.config/obs-studio/loading/index.html",
|
||||
"is_local_file": false,
|
||||
"url": "http://127.0.0.1:1118/loading",
|
||||
"width": 2560,
|
||||
"height": 1336,
|
||||
"restart_when_active": true,
|
||||
@@ -419,8 +419,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/music/index.html",
|
||||
"is_local_file": false,
|
||||
"url": "http://127.0.0.1:1118/music-daemon",
|
||||
"width": 400,
|
||||
"height": 100,
|
||||
"shutdown": true,
|
||||
@@ -456,8 +456,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/game/index.html",
|
||||
"is_local_file": false,
|
||||
"url": "http://127.0.0.1:1118/game",
|
||||
"width": 2560,
|
||||
"height": 1336,
|
||||
"fps": 15,
|
||||
@@ -494,8 +494,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/goodbye/index.html",
|
||||
"is_local_file": false,
|
||||
"url": "http://127.0.0.1:1118/goodbye",
|
||||
"width": 2560,
|
||||
"height": 1336,
|
||||
"fps_custom": true,
|
||||
@@ -536,7 +536,7 @@
|
||||
"mixers": 255,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 0.20938195288181305,
|
||||
"volume": 0.19247779250144958,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
@@ -567,7 +567,7 @@
|
||||
"mixers": 255,
|
||||
"sync": 0,
|
||||
"flags": 0,
|
||||
"volume": 0.0053968820720911026,
|
||||
"volume": 0.01389647088944912,
|
||||
"balance": 0.5,
|
||||
"enabled": true,
|
||||
"muted": false,
|
||||
@@ -593,8 +593,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/desktop/index.html",
|
||||
"is_local_file": false,
|
||||
"url": "http://127.0.0.1:1118/desktop",
|
||||
"width": 2560,
|
||||
"height": 1336,
|
||||
"fps": 15,
|
||||
@@ -631,8 +631,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/music-box/index.html",
|
||||
"is_local_file": false,
|
||||
"url": "http://127.0.0.1:1118/music-box",
|
||||
"width": 2560,
|
||||
"height": 1336,
|
||||
"fps": 30,
|
||||
@@ -670,9 +670,9 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"settings": {
|
||||
"is_local_file": true,
|
||||
"local_file": "/home/jin/.config/obs-studio/music-box/widget.html",
|
||||
"is_local_file": false,
|
||||
"undo_uuid": "f662d513-6fe6-49fd-99f6-ea7f44e8de18",
|
||||
"url": "http://127.0.0.1:1118/music-box/widget",
|
||||
"width": 549,
|
||||
"height": 880,
|
||||
"fps": 15,
|
||||
@@ -709,7 +709,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"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,
|
||||
"sync": 0,
|
||||
@@ -741,7 +742,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"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,
|
||||
"sync": 0,
|
||||
@@ -773,7 +775,8 @@
|
||||
"id": "browser_source",
|
||||
"versioned_id": "browser_source",
|
||||
"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,
|
||||
"height": 1331,
|
||||
"fps": 15,
|
||||
@@ -3683,8 +3686,8 @@
|
||||
"name": "Good Bye"
|
||||
}
|
||||
],
|
||||
"current_scene": "Good Bye",
|
||||
"current_program_scene": "Good Bye",
|
||||
"current_scene": "Music Box",
|
||||
"current_program_scene": "Music Box",
|
||||
"canvases": [],
|
||||
"current_transition": "Luma Wipe",
|
||||
"transition_duration": 1000,
|
||||
@@ -3716,7 +3719,7 @@
|
||||
"saved_projectors": [],
|
||||
"preview_locked": false,
|
||||
"scaling_enabled": false,
|
||||
"scaling_level": -12,
|
||||
"scaling_level": -5,
|
||||
"scaling_off_x": 0.0,
|
||||
"scaling_off_y": 0.0,
|
||||
"modules": {
|
||||
|
||||
3757
basic/scenes/Default_Stream_HUD.json.pre-webapp
Normal file
@@ -47,7 +47,7 @@ RIG_NAME_TC="${RIG_NAME^}" # title-case: ignia → Ignia
|
||||
|
||||
# ── output helpers ─────────────────────────────────────────────────────────
|
||||
PHASE=0
|
||||
TOTAL=9
|
||||
TOTAL=10
|
||||
step() { PHASE=$((PHASE+1)); printf '\n\033[1;36m[%d/%d] %s\033[0m\n' "$PHASE" "$TOTAL" "$*"; }
|
||||
ok() { printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||
skip() { printf ' \033[90m·\033[0m %s\n' "$*"; }
|
||||
@@ -95,6 +95,8 @@ PACMAN_PKGS=(
|
||||
mpd mpc
|
||||
avahi nss-mdns
|
||||
fontconfig
|
||||
php composer
|
||||
supervisor
|
||||
)
|
||||
|
||||
if command -v pacman >/dev/null; then
|
||||
@@ -218,7 +220,7 @@ fi
|
||||
|
||||
ASSET_DIRS=(
|
||||
"$HOME/Videos"
|
||||
"$HOME/HDD/Music"
|
||||
"$HOME/Music"
|
||||
"$HOME/HDD/Images/Gifs"
|
||||
"$HOME/cloud.jakubzych.com/_img/logos"
|
||||
)
|
||||
@@ -370,7 +372,7 @@ write_mpd_conf() {
|
||||
# ~/.config/mpd/mpd.conf — generated by scripts/deploy-rig.sh.
|
||||
# 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"
|
||||
db_file "~/.config/mpd/database"
|
||||
state_file "~/.config/mpd/state"
|
||||
@@ -419,13 +421,13 @@ else
|
||||
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
|
||||
mpc update >/dev/null 2>&1 || warn "mpc update failed (mpd not yet running?)"
|
||||
ok "MPD library scan kicked off"
|
||||
fi
|
||||
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
|
||||
|
||||
# Avahi for zeroconf advertisement (system service).
|
||||
@@ -558,17 +560,87 @@ PY
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$OBS_DIR/vendor/obs-config.js" ]]; then
|
||||
ok "vendor/obs-config.js present"
|
||||
if [[ -f "$OBS_DIR/webapp/public/js/obs-config.js" ]]; then
|
||||
ok "webapp/public/js/obs-config.js present"
|
||||
else
|
||||
if would "bash scripts/setup.sh"; then
|
||||
bash "$DIR/setup.sh" || warn "setup.sh failed — re-run after OBS WS port is reachable"
|
||||
if would "php artisan rig:setup"; then
|
||||
( cd "$OBS_DIR/webapp" && php artisan rig:setup ) \
|
||||
|| warn "rig:setup failed — re-run after OBS WS port is reachable"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 9 — secrets stub + final report
|
||||
# Phase 9 — webapp (Laravel) + supervisord
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
step "Webapp (Laravel) + supervisord program"
|
||||
|
||||
if [[ -d "$OBS_DIR/webapp" ]]; then
|
||||
if [[ -d "$OBS_DIR/webapp/vendor" ]] && [[ -f "$OBS_DIR/webapp/vendor/autoload.php" ]]; then
|
||||
ok "webapp/vendor present (composer install already ran)"
|
||||
else
|
||||
if would "composer install in webapp/"; then
|
||||
( cd "$OBS_DIR/webapp" && composer install --no-dev --optimize-autoloader ) \
|
||||
|| warn "composer install failed — re-run manually if php/composer were just installed"
|
||||
ok "composer install complete"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$OBS_DIR/webapp/.env" ]]; then
|
||||
ok "webapp/.env present"
|
||||
elif [[ -f "$OBS_DIR/webapp/.env.example" ]]; then
|
||||
if would "cp .env.example .env + php artisan key:generate"; then
|
||||
cp "$OBS_DIR/webapp/.env.example" "$OBS_DIR/webapp/.env"
|
||||
( cd "$OBS_DIR/webapp" && php artisan key:generate ) >/dev/null
|
||||
ok "seeded webapp/.env from .env.example"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f /etc/supervisor.d/obs-webapp.conf ]]; then
|
||||
ok "/etc/supervisor.d/obs-webapp.conf already installed"
|
||||
else
|
||||
if would "sudo install scripts/obs-webapp.supervisord.conf → /etc/supervisor.d/"; then
|
||||
sudo install -m 644 "$DIR/obs-webapp.supervisord.conf" /etc/supervisor.d/obs-webapp.conf
|
||||
ok "installed /etc/supervisor.d/obs-webapp.conf"
|
||||
fi
|
||||
fi
|
||||
|
||||
if systemctl is-enabled supervisord >/dev/null 2>&1; then
|
||||
ok "supervisord enabled"
|
||||
else
|
||||
if would "sudo systemctl enable --now supervisord"; then
|
||||
sudo systemctl enable --now supervisord
|
||||
ok "enabled supervisord"
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( ! CHECK_ONLY )); then
|
||||
sudo supervisorctl reread >/dev/null 2>&1 || true
|
||||
sudo supervisorctl update >/dev/null 2>&1 || true
|
||||
if sudo supervisorctl status obs-webapp 2>/dev/null | grep -q RUNNING; then
|
||||
ok "obs-webapp program RUNNING"
|
||||
else
|
||||
if would "sudo supervisorctl start obs-webapp"; then
|
||||
sudo supervisorctl start obs-webapp 2>&1 | sed 's/^/ /' || \
|
||||
warn "obs-webapp didn't start — check storage/logs/supervisord.err.log"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( ! CHECK_ONLY )); then
|
||||
sleep 1
|
||||
if curl -fsS -o /dev/null http://127.0.0.1:1118/landing; then
|
||||
ok "http://127.0.0.1:1118/landing responding"
|
||||
else
|
||||
warn "127.0.0.1:1118/landing not reachable yet — check obs-webapp status"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
warn "webapp/ missing — clone the repo properly first"
|
||||
fi
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 10 — secrets stub + final report
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
step "Secrets stub + final report"
|
||||
|
||||
@@ -590,9 +662,9 @@ stub_env "$OBS_DIR/twitch-bot/.env.ophi118"
|
||||
|
||||
# Refresh telemetry so the rig name is correct on this machine.
|
||||
if (( ! CHECK_ONLY )); then
|
||||
if would "scripts/telemetry.sh --collect --no-review (rig snapshot)"; then
|
||||
bash "$DIR/telemetry.sh" --collect --no-review || \
|
||||
warn "telemetry.sh --collect failed — re-run interactively to review"
|
||||
if would "php artisan rig:telemetry --collect (rig snapshot)"; then
|
||||
( cd "$OBS_DIR/webapp" && php artisan rig:telemetry --collect ) || \
|
||||
warn "rig:telemetry failed — re-run interactively to review"
|
||||
ok "telemetry refreshed (rig=$RIG_NAME_TC)"
|
||||
fi
|
||||
fi
|
||||
@@ -620,7 +692,7 @@ cat <<DONE
|
||||
systemctl --user restart obs-twitch-bot.service
|
||||
|
||||
5. Review telemetry interactively:
|
||||
bash scripts/telemetry.sh --collect
|
||||
cd webapp && php artisan rig:telemetry --collect
|
||||
|
||||
6. Launch OBS:
|
||||
flatpak run com.obsproject.Studio
|
||||
@@ -633,6 +705,8 @@ cat <<DONE
|
||||
Sanity checks:
|
||||
pactl list short sinks | grep -E 'mpd_stream|discord_stream'
|
||||
systemctl --user status mpd obs-mpd-bridge obs-twitch-bot
|
||||
sudo supervisorctl status obs-webapp
|
||||
curl -fsS http://127.0.0.1:1118/landing | head -1
|
||||
mpc status
|
||||
ss -tlnp | grep 4455
|
||||
|
||||
|
||||
31
scripts/obs-webapp.supervisord.conf
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
- **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.
|
||||
|
||||

|
||||
|
||||
### Scenes index — `/scenes`
|
||||
|
||||
All overlay URLs the OBS browser-sources point at, plus the data endpoints.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
30
webapp/app/Console/Commands/RigCmdCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
101
webapp/app/Console/Commands/RigLoadingCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
84
webapp/app/Console/Commands/RigSetupCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
42
webapp/app/Console/Commands/RigTelemetryCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
71
webapp/app/Http/Controllers/AudioController.php
Normal 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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
8
webapp/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
35
webapp/app/Http/Controllers/DataController.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
20
webapp/app/Http/Controllers/MusicCommandController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
159
webapp/app/Http/Controllers/OnboardController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
13
webapp/app/Http/Controllers/Overlays/DesktopController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
webapp/app/Http/Controllers/Overlays/GameController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/GoodbyeController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/LandingController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
webapp/app/Http/Controllers/Overlays/LoadingController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
webapp/app/Http/Controllers/Overlays/MusicBoxController.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
14
webapp/app/Providers/RigDataServiceProvider.php
Normal 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);
|
||||
}
|
||||
}
|
||||
186
webapp/app/Services/HardwareSnapshot.php
Normal 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;
|
||||
}
|
||||
}
|
||||
158
webapp/app/Services/ObsWsClient.php
Normal 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 30–80 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));
|
||||
}
|
||||
}
|
||||
93
webapp/app/Services/TwitchHelix.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
78
webapp/app/Support/OldenEra.php
Normal 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';
|
||||
}
|
||||
}
|
||||
95
webapp/app/Support/RigData.php
Normal 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
@@ -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
@@ -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
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
webapp/bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\RigDataServiceProvider::class,
|
||||
];
|
||||
49
webapp/composer.json
Normal 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
126
webapp/config/app.php
Normal 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
@@ -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
@@ -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
@@ -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),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
webapp/config/filesystems.php
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||
],
|
||||
];
|
||||
38
webapp/config/services.php
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
../resources/img/favicon.ico
|
||||
1
webapp/public/games
Symbolic link
@@ -0,0 +1 @@
|
||||
../resources/games
|
||||
20
webapp/public/index.php
Normal 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());
|
||||
28
webapp/public/js/crt-static.js
Normal 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
@@ -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);
|
||||
46
webapp/public/js/obs-ws-bootstrap.js
Normal 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);
|
||||
237
webapp/public/js/obs-ws-mini.js
Normal file
@@ -0,0 +1,237 @@
|
||||
// Minimal OBS WebSocket v5 client (JSON variant).
|
||||
// Just what the music daemon + loading overlay need:
|
||||
// - connect with optional auth (HMAC-SHA256 challenge)
|
||||
// - call('BroadcastCustomEvent', { eventData: { ... } })
|
||||
// - onCustom(cb) → fires for incoming CustomEvent broadcasts
|
||||
//
|
||||
// Protocol reference:
|
||||
// https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md
|
||||
//
|
||||
// Exposes: window.OBSWSMini
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
// Pure-JS SHA-256 (FIPS 180-4) → base64. Used when crypto.subtle is
|
||||
// unavailable — e.g. OBS's CEF browser source, where file:// URLs do
|
||||
// not grant secure-context status, so window.crypto.subtle is undefined.
|
||||
// Input is treated as a JS string and encoded UTF-8 before hashing.
|
||||
function _sha256b64Pure(str) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let c = str.charCodeAt(i);
|
||||
if (c < 0x80) bytes.push(c);
|
||||
else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
|
||||
else if (c < 0xd800 || c >= 0xe000) {
|
||||
bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
} else {
|
||||
i++;
|
||||
c = 0x10000 + (((c & 0x3ff) << 10) | (str.charCodeAt(i) & 0x3ff));
|
||||
bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f),
|
||||
0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
|
||||
}
|
||||
}
|
||||
const bitLen = bytes.length * 8;
|
||||
bytes.push(0x80);
|
||||
while (bytes.length % 64 !== 56) bytes.push(0);
|
||||
const high = Math.floor(bitLen / 0x100000000);
|
||||
const low = bitLen >>> 0;
|
||||
for (let i = 3; i >= 0; i--) bytes.push((high >>> (i * 8)) & 0xff);
|
||||
for (let i = 3; i >= 0; i--) bytes.push((low >>> (i * 8)) & 0xff);
|
||||
|
||||
const H = new Uint32Array([
|
||||
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
||||
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
||||
]);
|
||||
const K = [
|
||||
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
||||
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
||||
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
||||
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
||||
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
||||
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
||||
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
||||
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
|
||||
];
|
||||
const ROTR = (x, n) => (x >>> n) | (x << (32 - n));
|
||||
const W = new Uint32Array(64);
|
||||
for (let block = 0; block < bytes.length; block += 64) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
W[i] = ((bytes[block + i*4] << 24) |
|
||||
(bytes[block + i*4+1] << 16) |
|
||||
(bytes[block + i*4+2] << 8) |
|
||||
bytes[block + i*4+3]) >>> 0;
|
||||
}
|
||||
for (let i = 16; i < 64; i++) {
|
||||
const s0 = ROTR(W[i-15], 7) ^ ROTR(W[i-15], 18) ^ (W[i-15] >>> 3);
|
||||
const s1 = ROTR(W[i-2], 17) ^ ROTR(W[i-2], 19) ^ (W[i-2] >>> 10);
|
||||
W[i] = (W[i-16] + s0 + W[i-7] + s1) >>> 0;
|
||||
}
|
||||
let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const S1 = ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25);
|
||||
const ch = (e & f) ^ (~e & g);
|
||||
const t1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
|
||||
const S0 = ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22);
|
||||
const mj = (a & b) ^ (a & c) ^ (b & c);
|
||||
const t2 = (S0 + mj) >>> 0;
|
||||
h = g; g = f; f = e; e = (d + t1) >>> 0;
|
||||
d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
||||
}
|
||||
H[0]=(H[0]+a)>>>0; H[1]=(H[1]+b)>>>0; H[2]=(H[2]+c)>>>0; H[3]=(H[3]+d)>>>0;
|
||||
H[4]=(H[4]+e)>>>0; H[5]=(H[5]+f)>>>0; H[6]=(H[6]+g)>>>0; H[7]=(H[7]+h)>>>0;
|
||||
}
|
||||
let bin = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
bin += String.fromCharCode((H[i]>>>24)&0xff, (H[i]>>>16)&0xff, (H[i]>>>8)&0xff, H[i]&0xff);
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
class OBSWSMini extends EventTarget {
|
||||
constructor(url, password) {
|
||||
super();
|
||||
this.url = url;
|
||||
this.password = password || '';
|
||||
this._reqId = 0;
|
||||
this._pending = new Map();
|
||||
this.ws = null;
|
||||
this.identified = false;
|
||||
}
|
||||
|
||||
async _sha256b64(str) {
|
||||
// Prefer WebCrypto when available (HTTPS/localhost contexts).
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle && crypto.subtle.digest) {
|
||||
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = '';
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
}
|
||||
return _sha256b64Pure(str);
|
||||
}
|
||||
|
||||
async _authString(salt, challenge) {
|
||||
const secret = await this._sha256b64(this.password + salt);
|
||||
return await this._sha256b64(secret + challenge);
|
||||
}
|
||||
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let identified = false;
|
||||
let settled = false;
|
||||
const settle = (fn, val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
fn(val);
|
||||
};
|
||||
|
||||
// Hard timeout — if the server never sends Identified within 10s,
|
||||
// give up so the caller can show an error instead of hanging.
|
||||
const timeout = setTimeout(() => {
|
||||
if (!identified) {
|
||||
try { this.ws?.close(); } catch {}
|
||||
settle(reject, new Error('connect timeout (no Identified within 10s)'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.onopen = () => console.log('[OBSWS] socket open →', this.url);
|
||||
this.ws.onerror = () => settle(reject, new Error('ws error'));
|
||||
this.ws.onclose = (ev) => {
|
||||
this.identified = false;
|
||||
console.log(`[OBSWS] socket closed (code=${ev.code} reason='${ev.reason || ''}' clean=${ev.wasClean})`);
|
||||
if (!identified) {
|
||||
settle(reject, new Error(`closed before Identified (code=${ev.code} reason='${ev.reason || 'none'}')`));
|
||||
}
|
||||
this.dispatchEvent(new Event('close'));
|
||||
};
|
||||
|
||||
this.ws.onmessage = async (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch { return; }
|
||||
console.log('[OBSWS] ←', m.op, m.d?.eventType || m.d?.requestType || '');
|
||||
|
||||
try {
|
||||
if (m.op === 0) { // Hello
|
||||
let auth;
|
||||
if (m.d.authentication) {
|
||||
if (!this.password) {
|
||||
return settle(reject, new Error('server requires password'));
|
||||
}
|
||||
auth = await this._authString(m.d.authentication.salt, m.d.authentication.challenge);
|
||||
}
|
||||
const identifyMsg = {
|
||||
op: 1,
|
||||
d: { rpcVersion: 1, authentication: auth, eventSubscriptions: 0xFFFFFFFF },
|
||||
};
|
||||
console.log('[OBSWS] → 1 (Identify, auth-len=' + (auth?.length || 0) + ')');
|
||||
this.ws.send(JSON.stringify(identifyMsg));
|
||||
} else if (m.op === 2) { // Identified
|
||||
identified = true;
|
||||
this.identified = true;
|
||||
settle(resolve, this);
|
||||
this.dispatchEvent(new Event('identified'));
|
||||
} else if (m.op === 5) { // Event
|
||||
this.dispatchEvent(new CustomEvent('event', { detail: m.d }));
|
||||
if (m.d.eventType === 'CustomEvent') {
|
||||
this.dispatchEvent(new CustomEvent('custom', { detail: m.d.eventData || {} }));
|
||||
}
|
||||
} else if (m.op === 7) { // RequestResponse
|
||||
const p = this._pending.get(m.d.requestId);
|
||||
if (!p) return;
|
||||
this._pending.delete(m.d.requestId);
|
||||
if (m.d.requestStatus && m.d.requestStatus.result) {
|
||||
p.resolve(m.d.responseData || {});
|
||||
} else {
|
||||
p.reject(new Error(m.d.requestStatus?.comment || 'request failed'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[OBSWS] message handler error:', e);
|
||||
settle(reject, new Error(`message handler error: ${e.message}`));
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
call(requestType, requestData) {
|
||||
if (!this.identified) return Promise.reject(new Error('not identified'));
|
||||
const requestId = `r_${++this._reqId}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
this._pending.set(requestId, { resolve, reject });
|
||||
this.ws.send(JSON.stringify({
|
||||
op: 6,
|
||||
d: { requestType, requestId, requestData: requestData || {} },
|
||||
}));
|
||||
setTimeout(() => {
|
||||
if (this._pending.has(requestId)) {
|
||||
this._pending.delete(requestId);
|
||||
reject(new Error('request timeout'));
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: broadcast a namespaced custom event to all OBS WS clients.
|
||||
broadcast(eventType, payload) {
|
||||
return this.call('BroadcastCustomEvent', {
|
||||
eventData: Object.assign({ _type: eventType }, payload || {}),
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience: subscribe to incoming custom events. Filtered by _type if given.
|
||||
onCustom(typeOrCb, cb) {
|
||||
const filter = typeof typeOrCb === 'string' ? typeOrCb : null;
|
||||
const fn = filter ? cb : typeOrCb;
|
||||
this.addEventListener('custom', (e) => {
|
||||
const d = e.detail || {};
|
||||
if (filter && d._type !== filter) return;
|
||||
fn(d);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
root.OBSWSMini = OBSWSMini;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
2
webapp/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
11
webapp/resources/css/app.css
Normal 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';
|
||||
}
|
||||
1
webapp/resources/games/olden-era/README.md
Normal 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.
|
||||
BIN
webapp/resources/games/olden-era/factions/Dungeon.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
webapp/resources/games/olden-era/factions/Grove.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
webapp/resources/games/olden-era/factions/Hive.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
webapp/resources/games/olden-era/factions/Necropolis.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
webapp/resources/games/olden-era/factions/Schism.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
webapp/resources/games/olden-era/factions/Temple.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Enatee.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Glastor.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Kieran.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Lodos.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Motley.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Mouaren.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Rhea.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
webapp/resources/games/olden-era/heroes/Dungeon/Sister Deira.png
Normal file
|
After Width: | Height: | Size: 10 KiB |