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:
129
webapp/app/Http/Controllers/OnboardController.php
Normal file
129
webapp/app/Http/Controllers/OnboardController.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
777
webapp/resources/views/onboard.blade.php
Normal file
777
webapp/resources/views/onboard.blade.php
Normal 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 => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
})[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">> </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>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\AudioController;
|
use App\Http\Controllers\AudioController;
|
||||||
use App\Http\Controllers\DataController;
|
use App\Http\Controllers\DataController;
|
||||||
use App\Http\Controllers\MusicCommandController;
|
use App\Http\Controllers\MusicCommandController;
|
||||||
|
use App\Http\Controllers\OnboardController;
|
||||||
use App\Http\Controllers\Overlays\DesktopController;
|
use App\Http\Controllers\Overlays\DesktopController;
|
||||||
use App\Http\Controllers\Overlays\GameController;
|
use App\Http\Controllers\Overlays\GameController;
|
||||||
use App\Http\Controllers\Overlays\GoodbyeController;
|
use App\Http\Controllers\Overlays\GoodbyeController;
|
||||||
@@ -34,3 +35,7 @@ Route::get('/track', [AudioController::class, 'stream']);
|
|||||||
|
|
||||||
Route::post('/cmd/{type}', [MusicCommandController::class, 'send'])
|
Route::post('/cmd/{type}', [MusicCommandController::class, 'send'])
|
||||||
->whereIn('type', ['skip', 'prev', 'pause', 'resume']);
|
->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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user