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.
This commit is contained in:
Jakub Zych
2026-05-21 13:49:19 +02:00
parent b47f8fca83
commit 4ceff4015a
4 changed files with 912 additions and 0 deletions

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Services\TwitchHelix;
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)
{
return view('onboard', [
'manifest' => $rig->loading(),
'telemetry' => $rig->telemetry(),
'trackCount' => $rig->playlistCount(),
'twitchEnabled' => $twitch->enabled(),
]);
}
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): 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"
);
$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),
]);
}
/**
* 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

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

View File

@@ -0,0 +1,777 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OPHI-118 / onboard</title>
<link rel="stylesheet" href="{{ asset('css/hud.css') }}">
<style>
/* Onboard wizard is the only page that's not an OBS browser_source it
renders in a real desktop browser at arbitrary viewport sizes. So we
break from the canvas-pixel design language and use a centered
terminal-card layout with breathing room. */
body.overlay-body {
padding: 24px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 24px;
min-height: 100vh;
height: auto;
overflow: auto;
}
.wizard {
width: min(960px, 100%);
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 360px;
gap: 22px;
align-items: start;
}
@media (max-width: 800px) {
.wizard { grid-template-columns: 1fr; }
}
.panel {
position: relative;
background: var(--term-bg);
border: 2px solid var(--term-edge);
border-radius: 4px;
padding: 28px 30px;
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.55);
font-family: var(--mono);
overflow: visible;
}
.panel::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.22) 3px,
rgba(0,0,0,0.22) 4px
);
opacity: 0.45;
mix-blend-mode: multiply;
border-radius: 2px;
}
.panel > .pane-tab {
position: absolute;
top: -14px;
left: 28px;
padding: 2px 14px;
background: var(--bg);
color: var(--term-fg-bright);
font-size: 14px;
letter-spacing: 0.32em;
text-shadow: 0 0 6px var(--term-glow);
z-index: 1;
}
.panel > * { position: relative; z-index: 0; }
.form-row {
display: grid;
grid-template-columns: 14em 1fr;
gap: 14px;
align-items: start;
margin-bottom: 18px;
}
.form-row > label {
color: var(--term-fg-dim);
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 14px;
padding-top: 8px;
}
.form-row > .field { min-width: 0; }
.form-row .hint {
grid-column: 2;
color: var(--term-fg-dim);
font-size: 12px;
letter-spacing: 0.08em;
margin-top: 6px;
}
.form-row.error > label { color: var(--accent); }
.form-row.error .field input,
.form-row.error .field .toggle { border-color: var(--accent); }
.prompt-line {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--term-edge);
background: rgba(0, 30, 0, 0.45);
border-radius: 3px;
padding: 8px 12px;
transition: border-color .15s ease, box-shadow .15s ease;
}
.prompt-line:focus-within {
border-color: var(--term-fg-bright);
box-shadow: 0 0 0 1px var(--term-fg-bright), 0 0 18px rgba(95, 220, 98, 0.25);
}
.prompt-line .caret {
color: var(--term-fg-bright);
font-size: 14px;
}
.prompt-line input {
flex: 1 1 auto;
background: transparent;
border: 0;
outline: 0;
color: var(--term-fg-bright);
font: inherit;
font-size: 15px;
padding: 2px 0;
text-shadow: 0 0 6px var(--term-glow);
}
.prompt-line input::placeholder {
color: var(--term-fg-dim);
text-shadow: none;
}
/* Twitch search dropdown — appears below the game input. */
.search-wrap { position: relative; }
.search-results {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--term-bg);
border: 1px solid var(--term-edge);
border-radius: 3px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
z-index: 10;
max-height: 260px;
overflow-y: auto;
display: none;
}
.search-results.on { display: block; }
.search-results .row {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background .1s ease;
border-bottom: 1px solid rgba(80, 220, 100, 0.10);
}
.search-results .row:last-child { border-bottom: 0; }
.search-results .row:hover,
.search-results .row.active {
background: rgba(95, 220, 98, 0.10);
}
.search-results .row img {
width: 36px;
height: 48px;
object-fit: cover;
background: rgba(0,0,0,0.4);
border: 1px solid var(--term-edge);
}
.search-results .row .name { color: var(--term-fg-bright); }
.search-results .row .id { color: var(--term-fg-dim); font-size: 12px; }
.search-results .row .empty,
.search-results .row .error { color: var(--term-fg-dim); grid-column: 1 / -1; padding: 4px 0; }
.search-results .row .error { color: var(--accent); }
/* Number input — chevron buttons styled like terminal buttons. */
.stepper {
display: inline-grid;
grid-template-columns: 32px 5ch 32px;
align-items: stretch;
border: 1px solid var(--term-edge);
border-radius: 3px;
overflow: hidden;
background: rgba(0, 30, 0, 0.45);
}
.stepper button {
background: transparent;
border: 0;
color: var(--term-fg-bright);
font: inherit;
font-size: 16px;
cursor: pointer;
transition: background .12s ease;
}
.stepper button:hover { background: rgba(95, 220, 98, 0.15); }
.stepper input {
background: transparent;
border: 0;
outline: 0;
color: var(--term-fg-bright);
font: inherit;
font-size: 15px;
text-align: center;
text-shadow: 0 0 6px var(--term-glow);
-moz-appearance: textfield;
}
.stepper input::-webkit-outer-spin-button,
.stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* Toggle for camera / microphone — labeled ENABLED / DISABLED, swaps color. */
.toggle {
display: inline-flex;
border: 1px solid var(--term-edge);
border-radius: 3px;
overflow: hidden;
background: rgba(0, 30, 0, 0.45);
user-select: none;
}
.toggle button {
background: transparent;
border: 0;
color: var(--term-fg-dim);
font: inherit;
font-size: 14px;
letter-spacing: 0.18em;
padding: 8px 16px;
cursor: pointer;
transition: background .12s ease, color .12s ease;
}
.toggle button.on {
background: rgba(95, 220, 98, 0.18);
color: var(--term-fg-bright);
text-shadow: 0 0 6px var(--term-glow);
}
.toggle button.on.off-state {
background: rgba(230, 58, 46, 0.18);
color: var(--accent);
text-shadow: 0 0 6px rgba(230, 58, 46, 0.6);
}
/* Twitch push checkbox — boxy custom-styled. */
.check-row {
display: flex;
align-items: center;
gap: 12px;
color: var(--term-fg);
}
.check-row input[type=checkbox] { display: none; }
.check-row .checkbox {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px; height: 18px;
border: 1px solid var(--term-edge);
border-radius: 2px;
background: rgba(0, 30, 0, 0.45);
font-size: 14px;
color: var(--term-fg-bright);
cursor: pointer;
transition: border-color .12s ease;
}
.check-row.on .checkbox { border-color: var(--term-fg-bright); }
.check-row.on .checkbox::before { content: '✓'; }
.check-row.disabled { color: var(--term-fg-dim); }
.check-row.disabled .checkbox { cursor: not-allowed; opacity: 0.5; }
/* Submit row — primary action plus a small "save only" secondary. */
.actions {
display: flex;
justify-content: flex-end;
gap: 14px;
margin-top: 8px;
}
.btn {
background: rgba(0, 30, 0, 0.55);
color: var(--term-fg-bright);
font: inherit;
font-size: 14px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 12px 22px;
border: 1px solid var(--term-fg-bright);
border-radius: 3px;
cursor: pointer;
text-shadow: 0 0 6px var(--term-glow);
box-shadow: 0 0 14px rgba(95, 220, 98, 0.12);
transition: background .12s ease, box-shadow .12s ease;
}
.btn:hover {
background: rgba(95, 220, 98, 0.18);
box-shadow: 0 0 22px rgba(95, 220, 98, 0.28);
}
.btn.secondary {
color: var(--term-fg);
border-color: var(--term-edge);
box-shadow: none;
}
.btn:disabled {
color: var(--term-fg-dim);
border-color: var(--term-edge);
box-shadow: none;
cursor: not-allowed;
background: rgba(0, 30, 0, 0.3);
}
.btn .spinner {
display: inline-block;
width: 1ch;
text-align: center;
margin-right: 6px;
}
/* Preview pane (right column) renders the same lines the loading
overlay would type, so the streamer can see exactly what will show up. */
.preview-pane .terminal-mock {
background: rgba(0, 0, 0, 0.4);
border: 1px solid var(--term-edge);
border-radius: 3px;
padding: 14px 16px;
font-size: 13px;
line-height: 1.55;
min-height: 240px;
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
}
.preview-pane .terminal-mock .prompt { color: var(--term-fg-bright); }
.preview-pane .terminal-mock .dim { color: var(--term-fg-dim); }
.preview-pane .terminal-mock .gap { height: 0.5em; }
.preview-pane h3 {
color: var(--hud);
letter-spacing: 0.22em;
font-size: 13px;
margin-bottom: 12px;
text-transform: uppercase;
}
.preview-pane .footnote {
margin-top: 14px;
font-size: 12px;
color: var(--term-fg-dim);
letter-spacing: 0.08em;
}
.preview-pane .footnote a { color: var(--hud); text-decoration: none; }
.preview-pane .footnote a:hover { text-decoration: underline; }
/* Toast / result line shown after submit. */
#toast {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid var(--term-edge);
border-radius: 3px;
background: rgba(0, 30, 0, 0.45);
color: var(--term-fg);
font-size: 13px;
display: none;
}
#toast.on { display: block; }
#toast.err { border-color: var(--accent); color: var(--accent); }
#toast.ok { border-color: var(--term-fg-bright); }
#toast pre { white-space: pre-wrap; margin: 6px 0 0; color: var(--term-fg-dim); }
/* Header strip mirrors the on-stream HUD chrome. */
.hud {
font-size: 16px;
padding: 0 4px;
}
.hud .group { gap: 18px; }
/* Footer note. */
.foot-note {
text-align: center;
color: var(--term-fg-dim);
font-size: 12px;
letter-spacing: 0.18em;
}
</style>
</head>
<body class="overlay-body">
<header class="hud hud--onair">
<div class="group">
<span><span class="led"></span>ONBOARD</span>
<span>OPHI-118 // PROJECT MANIFEST</span>
</div>
<div class="group">
<span class="dim">RIG</span>
<span id="rig-name">{{ $telemetry['rig'] ?? '—' }}</span>
</div>
<div class="group">
<span class="dim">UTC</span>
<span id="clock">--:--:--</span>
</div>
</header>
<main class="wizard">
<section class="panel" id="form-panel">
<span class="pane-tab"> PROJECT LOAD </span>
<form id="onboard-form" novalidate>
<div class="form-row" id="row-game">
<label for="f-game">Game</label>
<div class="field search-wrap">
<div class="prompt-line">
<span class="caret"></span>
<input
type="text"
id="f-game"
name="game"
autocomplete="off"
spellcheck="false"
placeholder="start typing — e.g. Half-Life 2"
value="{{ $manifest['game'] ?? '' }}"
data-game-id="{{ $manifest['gameId'] ?? '' }}"
>
</div>
<div class="search-results" id="search-results" role="listbox"></div>
<div class="hint" id="game-hint">
@if (!$twitchEnabled)
<span style="color: var(--warn);"> TWITCH_CLIENT_ID / TWITCH_TOKEN not set game search disabled. Manifest will still save locally.</span>
@else
Twitch search runs as you type. Pick the matching category to lock the game id.
@endif
</div>
</div>
</div>
<div class="form-row">
<label for="f-subtitle">Subtitle</label>
<div class="field">
<div class="prompt-line">
<span class="caret"></span>
<input
type="text"
id="f-subtitle"
name="subtitle"
autocomplete="off"
spellcheck="false"
placeholder="mode / episode / note (optional)"
value="{{ $manifest['subtitle'] ?? '' }}"
>
</div>
<div class="hint">Becomes the Twitch stream title when "push to Twitch" is on (else falls back to the game name).</div>
</div>
</div>
<div class="form-row">
<label for="f-countdown">Countdown</label>
<div class="field">
<div class="stepper" role="group" aria-label="countdown minutes">
<button type="button" data-step="-1" aria-label="1"></button>
<input
type="number" id="f-countdown" name="countdownMin" min="0" max="120"
value="{{ $manifest['countdownMin'] ?? 5 }}"
>
<button type="button" data-step="+1" aria-label="+1">+</button>
</div>
<span style="margin-left: 12px; color: var(--term-fg-dim); letter-spacing: 0.12em;">minutes</span>
<div class="hint">"Starting in MM:00 — Transmission Incoming" countdown on the Project Loading scene.</div>
</div>
</div>
<div class="form-row">
<label>Camera</label>
<div class="field">
<div class="toggle" data-toggle="camera"
data-value="{{ ($manifest['camera'] ?? true) ? 'true' : 'false' }}">
<button type="button" data-val="true" class="{{ ($manifest['camera'] ?? true) ? 'on' : '' }}">ENABLED</button>
<button type="button" data-val="false" class="off-state {{ ($manifest['camera'] ?? true) ? '' : 'on' }}">DISABLED</button>
</div>
<input type="hidden" name="camera" value="{{ ($manifest['camera'] ?? true) ? '1' : '0' }}">
</div>
</div>
<div class="form-row">
<label>Microphone</label>
<div class="field">
<div class="toggle" data-toggle="microphone"
data-value="{{ ($manifest['microphone'] ?? true) ? 'true' : 'false' }}">
<button type="button" data-val="true" class="{{ ($manifest['microphone'] ?? true) ? 'on' : '' }}">ENABLED</button>
<button type="button" data-val="false" class="off-state {{ ($manifest['microphone'] ?? true) ? '' : 'on' }}">DISABLED</button>
</div>
<input type="hidden" name="microphone" value="{{ ($manifest['microphone'] ?? true) ? '1' : '0' }}">
</div>
</div>
<div class="form-row">
<label>Push to Twitch</label>
<div class="field">
<label class="check-row {{ $twitchEnabled ? 'on' : 'disabled' }}" id="push-row">
<input type="checkbox" name="pushTwitch" {{ $twitchEnabled ? 'checked' : '' }} {{ $twitchEnabled ? '' : 'disabled' }}>
<span class="checkbox"></span>
<span>Update channel title + category via Helix on save</span>
</label>
<div class="hint">Uses TWITCH_CLIENT_ID / TWITCH_TOKEN with the channel:manage:broadcast scope.</div>
</div>
</div>
<div class="actions">
<button type="submit" class="btn" id="submit-btn">
<span class="spinner" id="spinner"></span>WRITE MANIFEST
</button>
</div>
<div id="toast" role="status" aria-live="polite"></div>
</form>
</section>
<aside class="panel preview-pane">
<span class="pane-tab"> PREVIEW </span>
<h3>terminal output (loading scene)</h3>
<div class="terminal-mock" id="preview"></div>
<div class="footnote">
Rendered live as you edit. Mirrors the
<a href="/loading" target="_blank">/loading</a> overlay's terminal block.
Tracks in library: <strong>{{ $trackCount }}</strong>.
</div>
</aside>
</main>
<footer class="foot-note">
saves to webapp/storage/data/loading.json · OBS browser_source refresh-cache picks it up
</footer>
<script src="{{ asset('js/hud.js') }}"></script>
<script>
'use strict';
// ─── small utilities ───────────────────────────────────────────────────
const $ = sel => document.querySelector(sel);
const $$ = sel => Array.from(document.querySelectorAll(sel));
const debounce = (fn, ms) => {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
};
// ─── form state ────────────────────────────────────────────────────────
const form = $('#onboard-form');
const gameInput = $('#f-game');
const subInput = $('#f-subtitle');
const cdInput = $('#f-countdown');
const camHidden = form.querySelector('input[name="camera"]');
const micHidden = form.querySelector('input[name="microphone"]');
const pushInput = form.querySelector('input[name="pushTwitch"]');
const submitBtn = $('#submit-btn');
const spinner = $('#spinner');
const toast = $('#toast');
const preview = $('#preview');
const resultsEl = $('#search-results');
const rigName = $('#rig-name').textContent || 'UNKNOWN';
const TWITCH_ENABLED = @json($twitchEnabled);
// ─── toggle (camera / microphone) ──────────────────────────────────────
$$('.toggle').forEach(group => {
const hidden = form.querySelector(`input[name="${group.dataset.toggle}"]`);
group.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.dataset.val;
group.dataset.value = val;
group.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', b.dataset.val === val);
});
hidden.value = val === 'true' ? '1' : '0';
renderPreview();
});
});
});
// ─── stepper (countdown) ───────────────────────────────────────────────
$$('.stepper button').forEach(btn => {
btn.addEventListener('click', () => {
const step = parseInt(btn.dataset.step, 10) || 0;
const cur = parseInt(cdInput.value, 10) || 0;
cdInput.value = Math.max(0, Math.min(120, cur + step));
renderPreview();
});
});
cdInput.addEventListener('input', renderPreview);
subInput.addEventListener('input', renderPreview);
// ─── push-to-twitch checkbox ───────────────────────────────────────────
const pushRow = $('#push-row');
pushInput.addEventListener('change', () => {
pushRow.classList.toggle('on', pushInput.checked);
});
// ─── twitch search ─────────────────────────────────────────────────────
let activeIdx = -1;
let matches = [];
function setGameId(id) {
gameInput.dataset.gameId = id || '';
}
function clearGameId() {
if (gameInput.dataset.gameId) setGameId('');
}
function renderMatches(rows) {
matches = rows;
activeIdx = -1;
if (!rows.length) {
resultsEl.innerHTML = '<div class="row"><span class="empty">no matches</span></div>';
resultsEl.classList.add('on');
return;
}
resultsEl.innerHTML = rows.map((r, i) => {
const box = (r.boxArt || '').replace(/-{width}x{height}/, '-72x96');
return `
<div class="row" data-i="${i}" role="option">
${box ? `<img src="${box}" alt="">` : '<span></span>'}
<div>
<div class="name">${escapeHtml(r.name)}</div>
<div class="id">id ${escapeHtml(r.id)}</div>
</div>
<span class="dim" style="color: var(--term-fg-dim); font-size: 11px;"> select</span>
</div>
`;
}).join('');
resultsEl.classList.add('on');
resultsEl.querySelectorAll('.row').forEach(row => {
row.addEventListener('click', () => pickMatch(parseInt(row.dataset.i, 10)));
});
}
function pickMatch(i) {
const m = matches[i];
if (!m) return;
gameInput.value = m.name;
setGameId(m.id);
resultsEl.classList.remove('on');
renderPreview();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[c]);
}
const runSearch = debounce(async (q) => {
if (!TWITCH_ENABLED) { resultsEl.classList.remove('on'); return; }
if (q.length < 2) { resultsEl.classList.remove('on'); return; }
try {
const r = await fetch(`/onboard/search?q=${encodeURIComponent(q)}`);
if (!r.ok) {
const err = await r.json().catch(() => ({}));
resultsEl.innerHTML = `<div class="row"><span class="error">${escapeHtml(err.error || 'search failed')}</span></div>`;
resultsEl.classList.add('on');
return;
}
const body = await r.json();
renderMatches(body.matches || []);
} catch (e) {
resultsEl.innerHTML = `<div class="row"><span class="error">${escapeHtml(e.message)}</span></div>`;
resultsEl.classList.add('on');
}
}, 220);
gameInput.addEventListener('input', () => {
clearGameId();
runSearch(gameInput.value.trim());
renderPreview();
});
gameInput.addEventListener('keydown', (e) => {
if (!resultsEl.classList.contains('on')) return;
const rows = resultsEl.querySelectorAll('.row[data-i]');
if (!rows.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIdx = (activeIdx + 1) % rows.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIdx = (activeIdx - 1 + rows.length) % rows.length;
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault();
pickMatch(activeIdx);
return;
} else if (e.key === 'Escape') {
resultsEl.classList.remove('on');
return;
} else {
return;
}
rows.forEach((r, i) => r.classList.toggle('active', i === activeIdx));
});
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-wrap')) resultsEl.classList.remove('on');
});
// ─── live preview ──────────────────────────────────────────────────────
function renderPreview() {
const game = gameInput.value.trim();
const sub = subInput.value.trim();
const cd = parseInt(cdInput.value, 10) || 0;
const cam = camHidden.value === '1';
const mic = micHidden.value === '1';
const onOff = b => b ? 'ENABLED' : 'DISABLED';
const pad = n => String(n).padStart(2, '0');
const rig = (rigName || '?').toUpperCase();
const PROMPT = 'OPHI-118://> ';
const rows = [
{ prompt: PROMPT, text: 'loadproject --manifest' },
{ text: `detected rig :: ${rig} - initializing transmission...` },
{ gap: true },
{ text: `target :: ${(game || '—').toUpperCase()}` },
...(sub ? [{ text: `mode :: ${sub.toUpperCase()}` }] : []),
{ text: `camera :: ${onOff(cam)}` },
{ text: `microphone :: ${onOff(mic)}` },
{ text: `countdown :: ${pad(cd)}:00` },
{ gap: true },
{ text: 'all systems nominal', dim: true },
{ text: 'READY.' },
];
preview.innerHTML = rows.map(r => {
if (r.gap) return '<div class="gap"></div>';
const cls = r.dim ? ' dim' : '';
if (r.prompt) {
return `<div><span class="prompt">${escapeHtml(r.prompt)}</span><span class="${cls.trim()}">${escapeHtml(r.text)}</span></div>`;
}
return `<div><span class="prompt">&gt; </span><span class="${cls.trim()}">${escapeHtml(r.text)}</span></div>`;
}).join('');
}
// ─── submit ────────────────────────────────────────────────────────────
form.addEventListener('submit', async (e) => {
e.preventDefault();
toast.classList.remove('on', 'ok', 'err');
submitBtn.disabled = true;
spinner.textContent = '⟳';
const payload = {
game: gameInput.value.trim(),
gameId: gameInput.dataset.gameId || null,
subtitle: subInput.value.trim() || null,
countdownMin: parseInt(cdInput.value, 10) || 0,
camera: camHidden.value === '1',
microphone: micHidden.value === '1',
pushTwitch: pushInput.checked,
};
try {
const r = await fetch('/onboard', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(payload),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = body.message
? body.message + (body.errors ? '\n' + JSON.stringify(body.errors, null, 2) : '')
: `HTTP ${r.status}`;
toast.className = 'on err';
toast.innerHTML = `✗ submit failed<pre>${escapeHtml(msg)}</pre>`;
return;
}
const lines = [`✓ wrote ${body.path}`];
if (body.twitch) lines.push(`twitch: ${body.twitch}`);
lines.push('→ refresh the Project Loading browser source in OBS.');
toast.className = 'on ok';
toast.innerHTML = `<strong>manifest saved</strong><pre>${escapeHtml(lines.join('\n'))}</pre>`;
} catch (err) {
toast.className = 'on err';
toast.innerHTML = `✗ network error<pre>${escapeHtml(err.message)}</pre>`;
} finally {
submitBtn.disabled = false;
spinner.textContent = '';
}
});
// ─── boot ──────────────────────────────────────────────────────────────
renderPreview();
pushRow.classList.toggle('on', pushInput.checked);
</script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\AudioController;
use App\Http\Controllers\DataController;
use App\Http\Controllers\MusicCommandController;
use App\Http\Controllers\OnboardController;
use App\Http\Controllers\Overlays\DesktopController;
use App\Http\Controllers\Overlays\GameController;
use App\Http\Controllers\Overlays\GoodbyeController;
@@ -34,3 +35,7 @@ Route::get('/track', [AudioController::class, 'stream']);
Route::post('/cmd/{type}', [MusicCommandController::class, 'send'])
->whereIn('type', ['skip', 'prev', 'pause', 'resume']);
Route::get('/onboard', [OnboardController::class, 'show']);
Route::get('/onboard/search', [OnboardController::class, 'search']);
Route::post('/onboard', [OnboardController::class, 'submit']);