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