Compare commits

...

2 Commits

Author SHA1 Message Date
Jakub Zych
dd3493921b Styling fixes and Olden Era mmodule 2026-05-21 15:00:12 +02:00
Jakub Zych
4ceff4015a webapp: add /onboard wizard — web equivalent of php artisan rig:loading
Browser-based Project Loading manifest builder at http://127.0.0.1:1118/onboard.
Reuses the existing terminal/CRT styling (hud.css palette + scanlines +
phosphor glow) so it doesn't feel like a separate UI:

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

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

Falls back gracefully when TWITCH_CLIENT_ID / TWITCH_TOKEN are absent —
the search is disabled and the manifest still saves locally.
2026-05-21 13:49:19 +02:00
137 changed files with 1904 additions and 75 deletions

View File

@@ -1,58 +1,169 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p> # OPHI-118 webapp
<p align="center"> Laravel app that renders every on-stream overlay for the OPHI-118 Twitch rig and
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a> hosts the streamer's control surfaces. Runs under supervisord on `http://127.0.0.1:1118/`.
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel > Looking for the rig-wide picture (OBS, MPD, twitch-bot, deployment)?
> See [`../README.md`](../README.md). This file is just the webapp.
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: ## What's in here
- [Simple, fast routing engine](https://laravel.com/docs/routing). ### Control panel — `/`
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. Tile-based dashboard. Entry point from a browser bookmark; not used by OBS itself.
## Learning Laravel ![Control panel](resources/img/screenshots/home.webp)
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. - **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.
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. ### Onboard wizard — `/onboard`
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. 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.
## Agentic Development ![Onboard form](resources/img/screenshots/onboard1.webp)
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: - **Type-ahead game search** against the Twitch Helix `search/categories`
endpoint, debounced. ↑/↓/Enter/Esc keys to pick a match; locks the `gameId`.
- **Live preview** mirrors what the `/loading` overlay will render — every edit
re-renders the terminal-style output immediately.
- **Stepper** for the countdown, **ENABLED/DISABLED toggles** for camera/mic,
**push-to-Twitch** checkbox to commit the channel update via Helix.
- **Graceful fallback**: with `TWITCH_CLIENT_ID` / `TWITCH_ACCESS_TOKEN` unset,
the search disables and the manifest still saves locally.
![Onboard preview](resources/img/screenshots/onboard2.webp)
### Scenes index — `/scenes`
All overlay URLs the OBS browser-sources point at, plus the data endpoints.
![Scenes](resources/img/screenshots/scenes-page.webp)
### Overlay routes
Browser sources in `basic/scenes/Default_Stream_HUD.json` consume these. Edits
to the Blade views are picked up on the next OBS **Refresh cache** — no server
restart needed.
| Route | Purpose |
|---|---|
| `/landing` | Fallout-style "PLEASE STAND BY" + telemetry rotator |
| `/loading` | Terminal-style Project Loading + countdown |
| `/game` | Game HUD (camera-frame OBS-WS sync, status bar) |
| `/desktop` | Desktop HUD (camera + screen-capture frames) |
| `/goodbye` | Sign-off terminal + "OFFLINE FOR" counter |
| `/music-box` | Full-screen music box terminal |
| `/music-box/widget` | Compact 549×880 widget |
| `/music-box/cover` | Album-art widget (`?bars=0` for compact mode) |
| `/music-box/nc` | ncurses-styled 2-pane variant |
| `/music-daemon` | Passive WebSocket listener (diagnostic) |
### Data + control endpoints
| Route | Notes |
|---|---|
| `GET /data/playlist.js` | Returns `window.__PLAYLIST = {...};` with ETag |
| `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 ```bash
composer require laravel/boost --dev 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 boost:install 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:playlist # reindex audio dirs → storage/data/playlist.json
php artisan rig:cmd skip # CLI mirror of POST /cmd/skip
``` ```
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. ## Running it
## Contributing 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`.
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). ```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
```
## Code of Conduct For local hacking without supervisord:
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). ```bash
php artisan serve --host=127.0.0.1 --port=1118
```
## Security Vulnerabilities ## When to restart
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. | 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` |
## License ## Layout
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). ```
webapp/
├── app/
│ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading,
│ │ rig:playlist, 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
├── playlist.json
└── telemetry.json
```
## Config
`.env` keys the webapp cares about:
```
APP_URL=http://127.0.0.1:1118
OBS_WS_URL=ws://localhost:4455
OBS_WS_PASSWORD=… # written by `php artisan rig:setup`
TWITCH_CLIENT_ID=…
TWITCH_ACCESS_TOKEN=… # legacy TWITCH_TOKEN is also accepted
TWITCH_BROADCASTER_ID=…
RIG_NAME=…
OBS_PROFILE=ophi118
MUSIC_DIR_1=…
```
See [`.env.example`](.env.example) for the full set.

View File

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

View File

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

View File

@@ -10,8 +10,9 @@ use RuntimeException;
* Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py * Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py
* for use from `php artisan rig:loading`. * for use from `php artisan rig:loading`.
* *
* Requires TWITCH_CLIENT_ID + TWITCH_TOKEN (user OAuth token with the * Requires TWITCH_CLIENT_ID + TWITCH_ACCESS_TOKEN (user OAuth token with the
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env. * 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 class TwitchHelix
{ {

View File

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

View File

@@ -12,6 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [ $middleware->validateCsrfTokens(except: [
'cmd/*', 'cmd/*',
'onboard',
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -31,6 +31,7 @@ return [
|--------------------------------------------------------------------------- |---------------------------------------------------------------------------
*/ */
'rig_name' => env('RIG_NAME'), 'rig_name' => env('RIG_NAME'),
'streamer_nick' => env('STREAMER_NICK', 'OPHI118'),
'obs_studio_dir' => env('OBS_STUDIO_DIR', base_path('..')), 'obs_studio_dir' => env('OBS_STUDIO_DIR', base_path('..')),
'obs_profile' => env('OBS_PROFILE', 'ophi118'), 'obs_profile' => env('OBS_PROFILE', 'ophi118'),
@@ -52,7 +53,9 @@ return [
*/ */
'twitch' => [ 'twitch' => [
'client_id' => env('TWITCH_CLIENT_ID'), 'client_id' => env('TWITCH_CLIENT_ID'),
'token' => env('TWITCH_TOKEN'), // 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'), 'broadcaster_id' => env('TWITCH_BROADCASTER_ID'),
], ],
]; ];

View File

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

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

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

1
webapp/public/games Symbolic link
View File

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

View File

@@ -0,0 +1 @@
Files here belongs to Unfrozen and are copies from https://wiki.hoodedhorse.com/Heroes_of_Might_and_Magic_Olden_Era - used only for game streaming purposes.

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

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