Files
obs-config/webapp/app/Http/Controllers/OnboardController.php
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

130 lines
4.7 KiB
PHP

<?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));
}
}