From 4ceff4015a5bbef9f6840066bb1ae9b664513777 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 21 May 2026 13:49:19 +0200 Subject: [PATCH] =?UTF-8?q?webapp:=20add=20/onboard=20wizard=20=E2=80=94?= =?UTF-8?q?=20web=20equivalent=20of=20php=20artisan=20rig:loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Http/Controllers/OnboardController.php | 129 +++ webapp/bootstrap/app.php | 1 + webapp/resources/views/onboard.blade.php | 777 ++++++++++++++++++ webapp/routes/web.php | 5 + 4 files changed, 912 insertions(+) create mode 100644 webapp/app/Http/Controllers/OnboardController.php create mode 100644 webapp/resources/views/onboard.blade.php diff --git a/webapp/app/Http/Controllers/OnboardController.php b/webapp/app/Http/Controllers/OnboardController.php new file mode 100644 index 0000000..b097f41 --- /dev/null +++ b/webapp/app/Http/Controllers/OnboardController.php @@ -0,0 +1,129 @@ + $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)); + } +} diff --git a/webapp/bootstrap/app.php b/webapp/bootstrap/app.php index 5aaf509..8763832 100644 --- a/webapp/bootstrap/app.php +++ b/webapp/bootstrap/app.php @@ -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 { diff --git a/webapp/resources/views/onboard.blade.php b/webapp/resources/views/onboard.blade.php new file mode 100644 index 0000000..17b1b8f --- /dev/null +++ b/webapp/resources/views/onboard.blade.php @@ -0,0 +1,777 @@ + + + + + +OPHI-118 / onboard + + + + + +
+
+ ONBOARD + OPHI-118 // PROJECT MANIFEST +
+
+ RIG + {{ $telemetry['rig'] ?? '—' }} +
+
+ UTC + --:--:-- +
+
+ +
+
+ ┤ PROJECT LOAD ├ + +
+
+ +
+
+ + +
+
+
+ @if (!$twitchEnabled) + ⚠ TWITCH_CLIENT_ID / TWITCH_TOKEN not set — game search disabled. Manifest will still save locally. + @else + Twitch search runs as you type. Pick the matching category to lock the game id. + @endif +
+
+
+ +
+ +
+
+ + +
+
Becomes the Twitch stream title when "push to Twitch" is on (else falls back to the game name).
+
+
+ +
+ +
+
+ + + +
+ minutes +
"Starting in MM:00 — Transmission Incoming" countdown on the Project Loading scene.
+
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ +
+ +
+ +
Uses TWITCH_CLIENT_ID / TWITCH_TOKEN with the channel:manage:broadcast scope.
+
+
+ +
+ +
+ +
+
+
+ + +
+ + + + + + + diff --git a/webapp/routes/web.php b/webapp/routes/web.php index 1c89372..bc1b5ff 100644 --- a/webapp/routes/web.php +++ b/webapp/routes/web.php @@ -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']);